diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d845b71 Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42e8a68 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv +pip-log.txt +pip-delete-this-directory.txt + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +/static/ +/media/ + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Environment files +.env +.env.local + +# Logs +logs/ +*.log + +# Misc +README.md +*.md +.coverage +htmlcov/ +.pytest_cache/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1afd6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +/logs/ +*.log +/config/secrets.json +.env +.idea +shikshalokam_mohini/**/__pycache__/ +build/ +.envrc +dist/ +**__pycache__/ +.sonar_lock +report-task.txt + +.coverage + + +.deepeval/ +graphify-out/ +config/qa-dev-prod-mitra-bucket.json +.vscode/ +.claude/ +.venv/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..56d91d3 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc5d449 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + DEBIAN_FRONTEND=noninteractive + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + postgresql-client \ + gcc \ + g++ \ + libpq-dev \ + libffi-dev \ + libssl-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +RUN mkdir -p /app/backend + +# Copy requirements file +COPY requirement.txt /app/backend + +# Install Python dependencies with increased timeout and retries +RUN cd /app/backend && pip install --no-cache-dir --upgrade pip --default-timeout=100 && \ + pip install --no-cache-dir --default-timeout=100 --retries 5 -r requirement.txt + +# Copy project files +COPY . /app/backend + +# Create logs directory +RUN mkdir -p /app/backend/logs + +# Create directory for static files +RUN mkdir -p /var/www/shikshalokam/static + +# Expose port (default Django development server port, adjust as needed) +EXPOSE 9000 + +WORKDIR /app/backend + +# Default command - can be overridden in docker-compose or run command +# For production, you might want to use daphne or gunicorn +CMD ["uvicorn", "shikshalokam_mohini.asgi:application", "--host", "0.0.0.0", "--port", "9000", "--workers", "4", "--ws-ping-interval", "30", "--ws-ping-timeout", "600"] \ No newline at end of file diff --git a/MARKDOWN_EXTRACTOR_INTEGRATION.md b/MARKDOWN_EXTRACTOR_INTEGRATION.md new file mode 100644 index 0000000..15b5ac7 --- /dev/null +++ b/MARKDOWN_EXTRACTOR_INTEGRATION.md @@ -0,0 +1,126 @@ +# Excel to Markdown Conversion Integration + +## Overview +Integrated a new `MarkdownExtractor` class that converts Excel and CSV files to clean Markdown format using the `tabulate` library. This improves LLM processing of spreadsheet data by providing structured, readable Markdown tables instead of raw CSV or pandas string output. + +## Changes Made + +### 1. New File: `markdown_extractor.py` +**Location:** `chatbot/utils/knowledge_service/extractor/markdown_extractor.py` + +**Class:** `MarkdownExtractor` + +**Key Features:** +- Converts Excel (.xlsx, .xls) and CSV files to Markdown format +- Uses `tabulate` library with 'pipe' format for clean Markdown tables +- Sanitizes cell content by converting line breaks to HTML `
` tags +- Post-processes Markdown to clean up formatting issues +- Extracts hyperlinks from Excel files using `openpyxl` +- Supports both limited and comprehensive content extraction + +**Main Methods:** +- `spreadsheet_to_markdown(content_bytes, filename)` - Core conversion logic +- `extract_limited_content(content_bytes, max_chars, filename)` - For LLM processing with character limits +- `extract_comprehensive_content_for_urls(content_bytes, filename)` - Full extraction with URL extraction +- `sanitize_cell_content(df)` - Cleans cell content before conversion +- `post_process_markdown(markdown_text)` - Cleans up generated Markdown + +### 2. Updated: `document_extractor.py` +**Location:** `chatbot/utils/knowledge_service/extractor/document_extractor.py` + +**Changes:** +1. **Import Added:** `from .markdown_extractor import MarkdownExtractor` +2. **Initialization:** Added `self.markdown_extractor = MarkdownExtractor(subdoc_max_chars)` in `__init__` +3. **Excel Processing:** Updated Excel file extraction to use `MarkdownExtractor` instead of `ExcelExtractor` +4. **CSV Processing:** Updated CSV file extraction to use `MarkdownExtractor` for consistency + +**Before (Excel):** +```python +text = self.excel_extractor.extract_limited_content(content_bytes, max_chars) +``` + +**After (Excel):** +```python +filename = f"spreadsheet.{file_extension}" +text = self.markdown_extractor.extract_limited_content(content_bytes, max_chars, filename) +``` + +**Before (CSV):** +```python +df = pd.read_csv(io.BytesIO(content_bytes), nrows=self.excel_max_rows) +text = df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) +``` + +**After (CSV):** +```python +filename = "spreadsheet.csv" +text = self.markdown_extractor.extract_limited_content(content_bytes, max_chars, filename) +``` + +## Benefits + +### 1. Better LLM Understanding +- Markdown tables are more structured and easier for LLMs to parse +- Clear column headers and row separators +- Preserves table structure better than CSV or pandas string output + +### 2. Cleaner Output +- Removes repetitive hyphens and excessive whitespace +- Standardizes table separators +- Handles multi-line cell content with `
` tags + +### 3. Consistent Processing +- Both Excel and CSV files now use the same conversion pipeline +- Uniform output format regardless of input file type + +### 4. URL Extraction +- Maintains hyperlink extraction from Excel files using `openpyxl` +- Returns both Markdown content and extracted URLs + +## Example Output + +### Input (Excel/CSV): +``` +Name, Age, City +John, 30, New York +Jane, 25, Los Angeles +``` + +### Output (Markdown): +```markdown +# spreadsheet.xlsx + +## Sheet1 + +| 0 | 1 | 2 | +|:-----|:----|:------------| +| Name | Age | City | +| John | 30 | New York | +| Jane | 25 | Los Angeles | + +--- +``` + +## Dependencies +- `pandas` - For reading Excel/CSV files +- `tabulate` - For converting DataFrames to Markdown tables +- `openpyxl` (optional) - For extracting hyperlinks from Excel files + +## Backward Compatibility +- `ExcelExtractor` class remains unchanged for any legacy code that might use it directly +- All existing functionality is preserved +- The integration is transparent to calling code + +## Testing Recommendations +1. Test with various Excel files (single/multiple sheets) +2. Test with CSV files +3. Test with files containing special characters +4. Test with files containing hyperlinks +5. Verify character limit truncation works correctly +6. Check that empty sheets are handled properly + +## Notes +- The `ExcelExtractor` class is still available and functional +- The `CSVExtractor` is no longer used for text extraction but may still be referenced elsewhere +- All logging statements are preserved for debugging +- Character limits are respected for LLM processing diff --git a/README.md b/README.md index 3a8fa26..a4912cd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,294 @@ -# commons-backend -commons-backend +# Shikshalokam Mohini Service – Local Setup +--- + +## Prerequisites + +* macOS +* Homebrew installed +* Python 3.10 +* Git + +--- + +## 1. Install Python 3.10 and uv Dependency Manager + +```bash +brew install python@3.10 +``` + +Verify installation: + +```bash +python3.10 --version +``` + +Install uv: +```base +pip install uv +``` + +--- + +## 2. Create a Virtual Environment (Outside Project Directory) + +Assuming your project is located at: + +``` +/Users/kunal/PycharmProjects/shikshalokam-mohini-service +``` + +### Step 1: Go to the project directory + +```bash +cd /Users/kunal/PycharmProjects/shikshalokam-mohini-service +``` + +### Step 2: Create the virtual environment + +```bash +uv venv +``` + +### Step 3: Activate the virtual environment + +```bash +source .venv/bin/activate +``` + +--- + +## 3. Install Project Dependencies + +```bash +uv sync +``` + +--- + +## 4. Load Environment Variables + +Make sure you have a `.env` file in the project root. + +```bash +export $(cat .env | xargs) +``` + +> ⚠️ Note: This exports variables only for the current shell session. + +--- + +## 5. Set Up Local PostgreSQL Database + +### 5.1 Install PostgreSQL + +Using Homebrew: + +```bash +brew install postgresql@14 +``` + +Start PostgreSQL: + +```bash +brew services start postgresql@14 +``` + +Verify it’s running: + +```bash +psql --version +``` + +--- + +### 5.2 Create Database and User + +Login to Postgres: + +```bash +psql postgres +``` + +Create a database user: + +```sql +CREATE USER mitra_user WITH PASSWORD 'mitra_password'; +``` + +Create the database: + +```sql +CREATE DATABASE mitra_db OWNER mitra_user; +``` + +Grant privileges: + +```sql +GRANT ALL PRIVILEGES ON DATABASE mitra_db TO mitra_user; +``` + +Exit psql: + +```sql +\q +``` + +--- + +### 5.3 Update `.env` File + +Add or update the following variables in your `.env` file: + +```env +DATABASE_NAME=mitra_db +DATABASE_USER=mitra_user +DATABASE_PASSWORD=mitra_password +DATABASE_HOST=localhost +DATABASE_PORT=5432 +``` + +### 5.4 Install PostgreSQL Python Driver + +Make sure this dependency exists (usually already in `requirements.in`): + +```bash +uv pip install psycopg2-binary +``` + +--- + +### 5.5 Run Django Migrations + +Ensure your virtual environment is active and env vars are loaded: + +```bash +export $(cat .env | xargs) +``` + +Run migrations: + +```bash +python3 manage.py migrate +``` + +(Optional) Create a superuser: + +You can accept the default name and give any password, keep email +empty and just press enter till completed. + +```bash +python3 manage.py createsuperuser +``` + +--- + +## Common Issues + +**Postgres not starting** + +```bash +brew services restart postgresql@14 +``` + +**Role does not exist** + +```bash +psql postgres +\du +``` + +**Port conflict** + +```bash +lsof -i :5432 +``` + + +## 6. Run the Application Server + +```bash +uvicorn shikshalokam_mohini.asgi:application \ + --host 0.0.0.0 \ + --port 9000 \ + --workers 4 \ + --ws-ping-interval 30 \ + --ws-ping-timeout 300 \ + --reload +``` + +--- + +## 7. Run Celery Worker + +Open a new terminal (with the same virtual environment activated): + +```bash +celery -A shikshalokam_mohini worker --pool=threads +``` + +--- + +## Notes + +* Ensure Redis or any other required backing services are running before starting Celery. +* Always activate `mitra_env` before running server or worker commands. + +--- + +Perfect, let’s plug **Redis setup** into the README cleanly 👌 +You can add this as the next section. + +--- + +## 8. Set Up Redis (Local, IF celery gives error) + +Redis is required for Celery and background task processing. + +--- + +### 8.1 Install Redis + +Using Homebrew: + +```bash +brew install redis +``` + +--- + +### 8.2 Start Redis Server + +Start Redis as a background service: + +```bash +brew services start redis +``` +--- + +### 8.3 Verify Redis Is Running + +```bash +redis-cli ping +``` + +Expected output: + +```text +PONG +``` + +--- + +## Common Redis Issues + +**Redis not running** + +```bash +brew services restart redis +``` + +**Port already in use** + +```bash +lsof -i :6379 +``` diff --git a/UBIQUITOUS_LANGUAGE.md b/UBIQUITOUS_LANGUAGE.md new file mode 100644 index 0000000..e332147 --- /dev/null +++ b/UBIQUITOUS_LANGUAGE.md @@ -0,0 +1,106 @@ +# Ubiquitous Language + +## Core Entities + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **Company** | An organisation that owns one or more bots and whose users interact with them | Tenant, organisation, client | +| **CompanyBot** | A specific bot configuration (LLM model, prompts, strategy, timeouts) belonging to a Company | Bot config, chatbot instance | +| **Profile** | A user record tied to a Company; holds personal data and auth credentials | User, account, login | +| **CompanyChat** | A single chat message (turn) sent by a Profile or the bot within a Session | Message, chat turn, utterance | +| **ChatSession** | An active conversation thread between a Profile and a CompanyBot; tracks current step, language, and status | Session object, chat thread | +| **Story** | A structured narrative document produced at the end of a ChatSession, authored by a Profile | Report, output, reflection doc | +| **StoryTranslation** | A vernacular (non-English) translation of a Story's content | Vernacular story, translated story | +| **Flow** | A named conversation configuration that groups a CompanyBot, State Machines, Voice configs, and language settings into a deployable unit | Route config, conversation config | + +## Conversation Structure + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **StateMachine** (CompanyStateMachine) | A single ordered step in a structured bot workflow; defines prompts, pre/post-process rules, and stage chats for that step | Wizard step, conversation node | +| **Step** | The integer position within a StateMachine sequence; determines which StateMachine is active in a ChatSession | Index, turn number | +| **ChatStage** | A named strand within a structured conversation (e.g. Welcome\_Strand, Courage\_Strand); used to scope which messages are passed to the LLM | Strand, phase | +| **BotStrategy** | The top-level conversation pattern used by a CompanyBot (oneshot, guided\_guest, guest\_discussion, common) | Mode, bot type | +| **ChatType** | The workflow variant of a session (e.g. Guided Reflection, One-Step Reflection, Mega PTM, PPS, Free Flow) | Flow type, session mode | +| **SessionFlow** | The named entry-point route that a user follows to start a session (e.g. guest-discussion, login, megaPTM, parent\_perception\_survey) | URL flow, route | + +## PTM & Survey Domain + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **PTM** | Parent-Teacher Meeting; the real-world event this service captures data about | Parent meeting | +| **PTM Reflection** | A Story produced from a Mega PTM session capturing a participant's PTM experience | PTM report, PTM story | +| **PPS (Parent Perception Survey)** | A survey flow capturing parents' perceptions of school quality and change | Parent survey | +| **PTM Experience Summary** | Free-text narrative within a PTM Reflection summarising the participant's PTM experience | Summary, notes | +| **Key Highlights** | Structured notable points extracted from a PTM Reflection | Takeaways, highlights | +| **Perceived Changes / Impact** | Outcomes and improvements observed by the participant that are recorded in a PTM Reflection | Impact section | +| **Role** | The participant's relationship to the school (e.g. parent, teacher, headmaster) as captured in a PTM session | Designation (use designation only for Profile, not PTM context) | + +## Translation & Voice + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **Voice** | A configured speech provider entry (STT, TTS, or text translation) attached to a CompanyBot | Voice config, audio config | +| **VoiceType** | The operation a Voice performs: SpeechToText, TextToSpeech, TextToText (translation), or Transliterate | Voice mode | +| **VoiceProvider** | The external service that handles a VoiceType (AI4Bharat, Google, Sarvam, OpenAI Whisper) | Speech engine | +| **Vernacular** | Any supported Indian language other than English (Hindi, Kannada, Telugu, Odia) | Regional language, local language | + +## Processing Pipeline + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **PreProcess** | An optional transformation applied to the prompt before the main LLM call; can modify or skip the current step | Pre-hook, prompt transform | +| **PostProcess** | An optional refinement applied to the LLM response after generation; can skip the next step | Post-hook, response transform | +| **DynamicContext** | Runtime-generated context injected into a prompt via SQL query or Python script | Dynamic prompt, live context | + +## Knowledge Service + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **Media** | An uploaded document (PDF, DOCX, Excel, CSV, image) attached to a CompanyBot for RAG or display | Document, file, attachment | +| **Tag** | A classification label applied to a Story or Media; can be manual or AI-extracted | Label, category | +| **Knowledge Service** | The subsystem that ingests Media, extracts text, and auto-tags content for LLM retrieval | Document service, RAG pipeline | + +## Actors + +| Term | Definition | Aliases to avoid | +| --- | --- | --- | +| **Guest** | An unauthenticated user who interacts via a guest flow without a verified Profile | Anonymous user, visitor | +| **Authenticated User** | A Profile with verified credentials who uses a login-gated flow | Logged-in user, auth user | +| **Moderator** | A Profile with elevated privileges for reviewing and managing content | Admin user, reviewer | + +## Relationships + +- A **Company** owns many **CompanyBots** and many **Profiles**. +- A **CompanyBot** is configured with one **BotStrategy** and one or more **StateMachines** (when `bot_type = STATE_MACHINE`). +- A **Flow** groups a **CompanyBot** with its **StateMachines**, **Voices**, and language settings into a deployable unit. +- A **ChatSession** is tied to one **Profile** and one **CompanyBot**; its `current_step` points to the active **StateMachine**. +- Each turn produces one **CompanyChat** record; multiple **CompanyChats** belong to one **ChatSession** via `session`. +- At the end of a PTM or reflection flow, one **Story** is created per **ChatSession**; a **Story** may have zero or more **StoryTranslations** for **Vernacular** languages. +- A **Tag** belongs to one **Story** or one **Media** record. + +## Example dialogue + +> **Dev:** "After the last step fires, do we create the Story immediately or wait?" +> +> **Domain expert:** "The Celery task `create_ptm_report` runs async. It calls `create_story_object`, which writes a **PTM Reflection** Story and, if the **ChatSession** language is vernacular, creates a **StoryTranslation** in that language." +> +> **Dev:** "So the Story's `stage` field — is that the same as the ChatSession's `current_step`?" +> +> **Domain expert:** "No. `stage` on Story is lifecycle: PENDING or COMPLETED. `current_step` on ChatSession is the integer index into the **StateMachine** sequence. Totally separate." +> +> **Dev:** "And the `flow` param passed into the task — is that a Flow model ID or a SessionFlowName string?" +> +> **Domain expert:** "It's a **SessionFlowName** string (e.g. `'megaPTM'`). It gets stored in `other_params` on the Story for downstream reporting. The actual **Flow** model is on the ChatSession." +> +> **Dev:** "Got it. One more: if a parent speaks Telugu, which component translates the bot's question before it's sent?" +> +> **Domain expert:** "The **Voice** record with `type = TextToText` and `language = 'te'` routes through the configured **VoiceProvider** (usually Sarvam or AI4Bharat). The translated text is stored as `translated_message` on **CompanyChat**." + +## Flagged ambiguities + +- **"session"** is overloaded: the `ChatSession` model object vs. the bare `session` CharField (a UUID string) used as the join key on `CompanyChat` and `Story`. Prefer **ChatSession** for the object and **session ID** for the string. +- **"stage"** means two different things: (1) `ChatStage` — a named conversation strand (Welcome\_Strand, Courage\_Strand) scoping which messages are sent to the LLM; (2) `Story.stage` — the lifecycle state (PENDING / COMPLETED). Always qualify: **ChatStage** vs. **Story stage**. +- **"flow"** appears as three distinct concepts: (1) the `Flow` model; (2) the `SessionFlowName` string enum; (3) the `ChatType` enum. Use **Flow** for the model, **SessionFlow** for the route/entry-point string, and **ChatType** for the workflow variant. +- **"status"** is used on `Company`/`Profile` (ACTIVE/INACTIVE entity status), `ChatSession` (STARTED/IN\_PROGRESS/COMPLETED/PAUSED), and `CompanyChat` (message-level status). Always prefix: **entity status**, **session status**, **message status**. +- **"role"** in PTM context (parent, teacher, headmaster) is distinct from **"profile\_type"** (USER, MODERATOR, PROSPECT) on the Profile model. Use **role** only for PTM participant context; use **profile type** for system access level. diff --git a/celery_worker.spec b/celery_worker.spec new file mode 100644 index 0000000..0be2451 --- /dev/null +++ b/celery_worker.spec @@ -0,0 +1,181 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_data_files + +celery_datas = collect_data_files('celery') + +datas = celery_datas + collect_data_files('coreschema') + + +hidden_imports = ['rest_framework_simplejwt', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'celery.fixups', + 'celery.fixups.django', + 'kombu.utils', + 'django', + 'django.conf', + 'django.core', + 'django.db', + 'django.db.backends', + 'django.db.backends.sqlite3', + 'django.http', + 'django.urls', + 'django.utils', + 'django.utils.formats', + 'importlib', + 'celery', + 'celery.app', + 'celery.app.task', + 'celery.loaders', + 'celery.loaders.app', + 'celery.fixups.django', + 'celery.concurrency.prefork', + 'celery.apps.worker', + 'coreschema', + 'ssl', + 'jinja', + 'google', + 'google_auth_oauthlib', + 'celery.worker.autoscale', + 'celery.worker.request', + 'celery.worker.consumer', + 'celery.utils.log', + 'celery.utils.dispatch', + 'celery.concurrency', + 'celery.utils.time', + 'celery.utils.imports', + 'celery.utils.dispatch', + 'celery.app.events', # Add other hidden imports as needed + 'celery.app.log', + 'celery.worker.autoscale', + 'celery.worker.consumer', + 'celery.worker.job', + 'celery.worker.state', + 'celery.worker.strategy', + 'celery.worker.pools', + 'celery.worker.components', + 'celery.worker', + 'celery.beat', + 'celery.backends', + 'celery.schedules', + 'celery.result', + 'celery.signals', + 'celery.utils', + 'celery.worker.direct', + 'celery.worker.kafka', + 'celery.worker.amqp', + 'celery.worker.redis', + 'celery.worker.database', + 'celery.worker.mongodb', + 'celery.worker.sqlalchemy', + 'celery.worker.rabbitmq', + 'celery.worker.celery', + 'celery.app.control', + 'celery.events.state', + 'celery.app.events', + 'celery.app.control', + 'celery.app.log', + 'celery.app.base', + 'celery.app.registry', + 'celery.app.task', + 'celery.app.trace', + 'celery.app.utils', + 'celery.beat', + 'celery.backends', + 'celery.result', + 'celery.signals', + 'celery.utils', + 'celery.worker', + 'celery.worker.autoscale', + 'celery.worker.control', + 'celery.worker.consumer', + 'celery.worker.job', + 'celery.worker.state', + 'celery.worker.strategy', + 'celery.worker.pools', + 'celery.worker.components', + 'celery.worker.direct', + 'celery.worker.kafka', + 'celery.worker.amqp', + 'celery.worker.redis', + 'celery.worker.database', + 'celery.worker.mongodb', + 'celery.worker.sqlalchemy', + 'celery.worker.rabbitmq', + 'celery.worker.celery', + 'celery.events.state', + 'celery.events', + 'celery.configuration', + 'celery.config', + 'celery.beat.schedulers', + 'celery.security', + 'celery.serialization', + 'celery.backends.base', + 'celery.backends.cache', + 'celery.backends.database', + 'celery.backends.redis', + 'celery.backends.rpc', + 'celery.backends.mongodb', + 'celery.backends.couchbase', + 'celery.backends.sqlalchemy', + 'celery.task', + 'celery.task.base', + 'celery.task.control', + 'celery.task.coordinator', + 'celery.task.state', + 'celery.task.tasks', + 'celery.debug', + 'celery.monitoring', + 'channels_redis', + 'channels_redis.core', + 'channels_redis.client', + 'channels_redis.protocol', + 'channels_redis.persistence', + 'channels_redis.exceptions', + 'channels_redis.router', + 'urls', + 'shikshalokam_mohini.asgi', + 'shikshalolam_mohini.urls', + 'celery.app.log', + 'celery.bin.worker', + 'celery.app.amqp', + 'kombu.transport.pyamqp', + 'celery.worker.components' +] + +a = Analysis( + ['/home/ubuntu/shikshalokam-mohini-service/shikshalokam-mohini-service/start_celery_worker.py'], + pathex=['/home/ubuntu/shikshalokam-mohini-service/shikshalokam-mohini-service'], + binaries=[], + datas=datas, + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='celery_worker', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/chatbot/.DS_Store b/chatbot/.DS_Store new file mode 100644 index 0000000..691697c Binary files /dev/null and b/chatbot/.DS_Store differ diff --git a/chatbot/__init__.py b/chatbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/admin/__init__.py b/chatbot/admin/__init__.py new file mode 100644 index 0000000..9a87a78 --- /dev/null +++ b/chatbot/admin/__init__.py @@ -0,0 +1,8 @@ +from .company_admin import * +from .profile_admin import * +from .story_admin import * +from .media_admin import * +from .bot_vernacular_admin import * +from .theme_admin import * +from .pdf_template_admin import * +from .i18n_admin import * \ No newline at end of file diff --git a/chatbot/admin/bot_vernacular_admin.py b/chatbot/admin/bot_vernacular_admin.py new file mode 100644 index 0000000..9a43473 --- /dev/null +++ b/chatbot/admin/bot_vernacular_admin.py @@ -0,0 +1,41 @@ +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import BotVernacular +from chatbot.models.story_vernacular_model import StoryVernacular + + +@admin.register(BotVernacular) +class BotVernacularAdmin(SimpleHistoryAdmin): + list_display = ('company_bot', 'language', 'introductory_message', 'created_at') + list_filter = ( + 'company_bot', + 'language', + CustomAdvanceDateFilter, + ) + inlines = [] + raw_id_fields = ('company_bot', ) + search_fields = ('company_bot__name', 'language', 'introductory_message') + date_hierarchy = 'created_at' + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('company_bot', 'language') + + +@admin.register(StoryVernacular) +class StoryVernacularAdmin(SimpleHistoryAdmin): + list_display = ('company_bot', 'language', 'created_at') + list_filter = ( + 'company_bot', + 'language', + CustomAdvanceDateFilter, + ) + inlines = [] + raw_id_fields = ('company_bot', ) + search_fields = ('company_bot__name', 'language') + date_hierarchy = 'created_at' + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('company_bot', 'language') \ No newline at end of file diff --git a/chatbot/admin/company_admin.py b/chatbot/admin/company_admin.py new file mode 100644 index 0000000..e902df8 --- /dev/null +++ b/chatbot/admin/company_admin.py @@ -0,0 +1,551 @@ +from django.contrib import admin +from django.db.models import Q +from pydantic import ValidationError +from simple_history.admin import SimpleHistoryAdmin +from .generic_upload_admin import BatchUploadMixin +from chatbot.filter.admin_filter import (CompanyChatCompanyFilter, ChatSessionFilter, ProfileCityFilter, + ProfileStateFilter, ProfileCompanyChatFilter, ProfileEmailFilter) +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import Company, Profile, ProfileType, CompanyBot, CompanyChat, ChatSession, \ + CompanyBotTypeChoices, Voice, VoiceProvider, VoiceType, ImageConfiguration, Flow +from chatbot.models.company_models import CompanyStateMachine +from chatbot.resources.resource import CompanyChatResource +from chatbot.resources.company_resource import ChatSessionResource +from django.shortcuts import redirect +from django.contrib import messages +import logging +from django.urls import path +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.forms import ModelForm, MultipleChoiceField, CheckboxSelectMultiple +from ..utils.admin_config.export_mixin import ExportAllFieldsMixin + + +class CompanyStateMachineAdmin(admin.TabularInline): + model = CompanyStateMachine + fk_name = 'company_bot' + extra = 1 + raw_id_fields = ['preprocess_bot', 'postprocess_bot'] + fields = ( + 'name', 'step', 'use_stage_chats', 'text_conversion_type', + 'bot_question', 'completion_criteria', 'context', 'tool_context', + 'operation_type', 'skip_if_authenticated', + 'preprocess_type', 'preprocess_prompt', 'preprocess_bot', 'preprocess_output_mode', + 'postprocess_type', 'postprocess_prompt', 'postprocess_bot', 'postprocess_output_mode', + 'skip_to_step', + ) + exclude = ('type',) # ✅ hide type + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('step') + + +class VoiceProviderAdmin(admin.TabularInline): + model = Voice + extra = 1 + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('type', 'language') + + def formfield_for_dbfield(self, db_field, request, **kwargs): + if db_field.name == "other_params": + kwargs["help_text"] = "Leave empty to auto-load provider defaults." + + return super().formfield_for_dbfield(db_field, request, **kwargs) + + +class CompanyAdmin(admin.ModelAdmin): + list_display = ('name', 'created_at', 'status') + list_filter = ( + CustomAdvanceDateFilter, + ) + search_fields = ('name',) + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def get_queryset(self, request): + qs = super().get_queryset(request) + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return qs + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return qs.filter(id=profile[0].company.id) + else: + return qs.none() + + +@admin.register(CompanyBot) +class CompanyBotAdmin(BatchUploadMixin, SimpleHistoryAdmin): + + list_display = ('name', 'company', 'created_at') + list_filter = ( + 'company', + 'name', + 'provider', + 'llm_model', + CustomAdvanceDateFilter, + ) + search_fields = ('name', 'company__name') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + inlines = [VoiceProviderAdmin] + actions = ['duplicate_bot', 'export_selected_bots'] + + enable_batch_upload = True + batch_load_foreign_keys = True + batch_upload_fields = ['name', 'company', 'provider', 'llm_model', 'context', 'max_token', 'route'] + + import_template_name = 'admin/import_export/import.html' + export_template_name = 'admin/import_export/export.html' + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + 'export/', + self.admin_site.admin_view(self.export_view), + name='chatbot_companybot_export', + ), + path( + 'import/', + self.admin_site.admin_view(self.import_view), + name='chatbot_companybot_import', + ), + ] + # Important: custom URLs must come before the default admin URLs + return custom_urls + urls + + def export_view(self, request): + """Handle export requests""" + from chatbot.views.admin.bot_admin_views import export_bots + return export_bots(request) + + def import_view(self, request): + """Handle import requests""" + from chatbot.views.admin.bot_admin_views import import_bots + return import_bots(request) + + def get_import_formats(self): + """Define allowed import formats""" + from import_export.formats import base_formats + return [base_formats.CSV, base_formats.XLSX, base_formats.JSON] + + def get_export_formats(self): + """Define allowed export formats""" + from import_export.formats import base_formats + return [base_formats.CSV, base_formats.XLSX, base_formats.JSON] + + def get_export_filename(self, request, queryset, file_format): + """Generate filename for exports""" + import datetime + date_str = datetime.datetime.now().strftime('%Y-%m-%d') + filename = f"company_bots_{date_str}" + return f"{filename}.{file_format.get_extension()}" + + def get_queryset(self, request): + qs = super().get_queryset(request) + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return qs + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return qs.filter(company=profile[0].company) + else: + return qs.none() + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + user = request.user + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if not user.is_superuser and len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + company_field = form.base_fields.get('company') + if company_field: + form.base_fields['company'].queryset = form.base_fields['company'].queryset.filter( + id=profile[0].company.id) + form.base_fields = {field_name: form.base_fields[field_name] for field_name in form.base_fields} + form.base_fields = {field_name: form.base_fields[field_name] for field_name in form.base_fields} + return form + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + # This method is called when the admin change form is rendered. + if object_id: + obj = self.model.objects.get(pk=object_id) + if obj.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + # If the bot_type is 'state machine', include the inline. + self.inlines = [VoiceProviderAdmin, CompanyStateMachineAdmin] + + else: + # Otherwise, no inlines. + self.inlines = [VoiceProviderAdmin] + else: + # For the add form, decide if you want the inline to be shown or not. + # This example assumes not. + self.inlines = [VoiceProviderAdmin] + return super().changeform_view(request, object_id, form_url, extra_context) + + # Sync Google glossary for TextToText voice providers after inline save + def save_formset(self, request, form, formset, change): + if getattr(formset, "model", None) is not Voice: + return super().save_formset(request, form, formset, change) + + from chatbot.translate.google import google_glossary + + logger = logging.getLogger("django") + + old_entries_by_pk = {} + for f in formset.forms: + if f.instance.pk: + v = Voice.objects.filter(pk=f.instance.pk).only("other_params").first() + if v and v.other_params and "glossary_entries" in v.other_params: + old_entries_by_pk[v.pk] = google_glossary.normalize_glossary_entries( + v.other_params.get("glossary_entries") + ) + + super().save_formset(request, form, formset, change) + + for f in formset.forms: + if not f.instance.pk or f.cleaned_data.get("DELETE"): + continue + inst = Voice.objects.filter(pk=f.instance.pk).first() + if not inst or inst.provider != VoiceProvider.GOOGLE or inst.type != VoiceType.TextToText: + continue + params = inst.other_params or {} + if "glossary_entries" not in params: + continue + new_entries = google_glossary.normalize_glossary_entries(params.get("glossary_entries")) + if not new_entries: + continue + if new_entries == old_entries_by_pk.get(inst.pk): + continue + try: + google_glossary.sync_glossary_for_voice(inst) + + messages.success( + request, + f"Google glossary synced successfully for Voice id={inst.pk}", + ) + except Exception as e: + logger.error( + "Glossary sync failed for Voice id=%s", + inst.pk, + exc_info=True + ) + messages.error( + request, + f"Google glossary sync failed for Voice id={inst.pk} (save completed): {e}", + ) + + + def duplicate_bot(self, request, queryset): + if queryset.count() != 1: + self.message_user(request, "Please select exactly one bot to duplicate.", level=messages.ERROR) + return + + original = queryset.first() + + # Duplicate the bot + new_bot = CompanyBot.objects.get(pk=original.pk) + new_bot.pk = None + new_bot.name = f"{original.name} (Copy)" + new_bot.save() + + # Duplicate VoiceProvider inlines + original_voice_providers = Voice.objects.filter(company_bot=original) + for voice in original_voice_providers: + voice.pk = None + voice.company_bot = new_bot + voice.save() + + # Duplicate StateMachine if present + if original.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + original_state_machines = CompanyStateMachine.objects.filter(company_bot=original) + for sm in original_state_machines: + sm.pk = None + sm.company_bot = new_bot + sm.save() + + self.message_user(request, "Bot duplicated successfully!", level=messages.SUCCESS) + return redirect(f"/admin/chatbot/companybot/{new_bot.id}/change/") + + def export_selected_bots(self, request, queryset): + """Custom export action""" + selected_ids = queryset.values_list('id', flat=True) + ids_str = ','.join(str(id) for id in selected_ids) + + # Use admin URL reverse with the app label and model name + info = self.model._meta.app_label, self.model._meta.model_name + url = reverse('admin:%s_%s_export' % info) + f'?ids={ids_str}' + return HttpResponseRedirect(url) + + export_selected_bots.short_description = "Export selected bots" + + def changelist_view(self, request, extra_context=None): + """Add custom buttons to the changelist view""" + extra_context = extra_context or {} + extra_context['custom_buttons'] = True + return super().changelist_view(request, extra_context=extra_context) + + duplicate_bot.short_description = "Duplicate selected bot" + + +@admin.register(CompanyChat) +class CompanyChatAdmin(ExportAllFieldsMixin, admin.ModelAdmin): + list_display = ('session', 'sender', 'receiver', 'message', 'translated_message', 'created_at', 'stage') + list_filter = ( + CustomAdvanceDateFilter, + ProfileCompanyChatFilter, + ProfileEmailFilter, + 'session', + CompanyChatCompanyFilter, + 'stage' + ) + search_fields = ('session', 'message__icontains', 'translated_message__icontains') + list_per_page = 20 + raw_id_fields = ('sender', 'receiver') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + export_filename = "company_chats.xlsx" + resource_class = CompanyChatResource + + def get_queryset(self, request): + qs = super().get_queryset(request) + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).select_related('company').first() + if request.user.is_superuser: + return qs.prefetch_related('sender__company', 'receiver__company') + elif profile and profile.profile_type == ProfileType.MODERATOR: + return qs.filter( + Q(sender__company=profile.company) | Q(receiver__company=profile.company) + ).prefetch_related('sender__company', 'receiver__company') + else: + return qs.none() + + def get_search_results(self, request, queryset, search_term): + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).select_related('company').first() + if not request.user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR: + if profile.company: + queryset = queryset.filter( + Q(sender__company=profile.company) | Q(receiver__company=profile.company) + ).prefetch_related('sender__company', 'receiver__company') + return queryset, use_distinct + + def get_list_filter(self, request): + user = request.user + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).select_related('company').first() + if not user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR: + company = profile.company + if company.slug == 'fmch': + return (CustomAdvanceDateFilter, ProfileCompanyChatFilter, + ProfileEmailFilter, 'session', ProfileCityFilter, ProfileStateFilter, 'message_type') + if company.slug == 'tfistaging': + return (CustomAdvanceDateFilter, ProfileCompanyChatFilter, + ProfileEmailFilter, 'session', CompanyChatCompanyFilter, 'stage') + return super().get_list_filter(request) + + +@admin.register(ChatSession) +class ChatSessionAdmin(ExportAllFieldsMixin, admin.ModelAdmin): + list_display = ( + 'session', 'get_first_name', 'session_status', 'session_type', 'current_question', 'total_steps', + 'created_at' + ) + list_filter = ( + 'session', + 'title', + ChatSessionFilter, + 'project_id', + 'session_status', + 'session_type', + CustomAdvanceDateFilter, + ) + search_fields = ('session', 'title', 'profile__first_name') + raw_id_fields = ('profile',) + readonly_fields = ('created_at',) + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + resource_class = ChatSessionResource + + def current_question(self, obj): + return obj.current_step + + current_question.short_description = 'Current Question' + + def total_steps(self, obj): + if obj.company_bot and CompanyStateMachine.objects.filter(company_bot=obj.company_bot).exists(): + return CompanyStateMachine.objects.filter(company_bot=obj.company_bot).count() + return 0 + + total_steps.short_description = 'Total Questions' + + def get_queryset(self, request): + qs = super().get_queryset(request).select_related('profile', 'company_bot') + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return qs + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return qs.filter(profile__company=profile[0].company).prefetch_related('profile__company') + else: + return qs.none() + + def get_list_display(self, request): + user = request.user + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if not user.is_superuser and len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return 'session', 'get_first_name', 'current_question', 'total_steps', 'session_status', 'created_at' + return 'session', 'get_first_name', 'current_question', 'total_steps', 'session_status', 'created_at' + + def get_first_name(self, obj): + return obj.profile.first_name if obj.profile else None + + get_first_name.short_description = 'First Name' + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + user = request.user + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + # Check if the user is a moderator + if not user.is_superuser and len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + # Exclude the fields for moderators + form.base_fields = {field_name: form.base_fields[field_name] for field_name in form.base_fields + if field_name not in ['current_step']} + return form + + +admin.site.register(Company, CompanyAdmin) + + +@admin.register(ImageConfiguration) +class ImageConfigurationAdmin(admin.ModelAdmin): + """Admin interface for Image Configuration model.""" + list_display = ('name', 'max_images', 'get_image_size_mb', 'created_at') + list_filter = ('created_at', 'max_images') + search_fields = ('name',) + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + fieldsets = ( + ('Basic Information', { + 'fields': ('name',) + }), + ('Image Constraints', { + 'fields': ('max_images', 'image_size'), + 'description': 'Configure image upload limits for this configuration.' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + def get_image_size_mb(self, obj): + """Display image size in MB.""" + return f"{obj.image_size / 1048576:.2f} MB" + get_image_size_mb.short_description = 'Max Image Size' + +LANGUAGE_CHOICES = [ + ("en", "English"), + ("hi", "Hindi"), + ("kn", "Kannada"), + ("te", "Telugu"), + ("or", "Odia"), +] + + +class FlowAdminForm(ModelForm): + languages = MultipleChoiceField( + choices=LANGUAGE_CHOICES, + required=False, + widget=CheckboxSelectMultiple, + help_text="Select one or more supported languages." + ) + + class Meta: + model = Flow + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + value = self.instance.languages if self.instance and self.instance.pk else None + self.fields["languages"].initial = value or ["en", "hi", "kn", "te"] + + def clean_languages(self): + value = self.cleaned_data.get("languages", []) + + if not isinstance(value, list): + raise ValidationError("Languages must be a list of language codes.") + + if len(value) != len(set(value)): + raise ValidationError("Language codes must be unique.") + + allowed = {code for code, _ in LANGUAGE_CHOICES} + invalid = [code for code in value if code not in allowed] + if invalid: + raise ValidationError(f"Invalid language codes: {', '.join(invalid)}") + + return value + + +@admin.register(Flow) +class FlowAdmin(SimpleHistoryAdmin): + """Admin interface for Flow model.""" + form = FlowAdminForm + + list_display = ( + 'flow_name', 'flow_route', 'bot', 'active', 'hidden', + 'user_type', 'created_at' + ) + list_filter = ( + 'active', 'hidden', 'user_type', + 'bot__company', CustomAdvanceDateFilter, 'create_story' + ) + search_fields = ('flow_name', 'flow_route', 'bot__name') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + raw_id_fields = ('bot', 'story_bot', 'parent_flow', 'image_config', 'story_validation_bot') + + fieldsets = ( + ('Basic Information', { + 'fields': ('flow_name', 'flow_route', 'languages') + }), + ('Bot Configuration', { + 'fields': ('bot', 'story_bot', 'story_validation_bot'), + 'description': 'Configure the bots associated with this flow.' + }), + ('Flow Settings', { + 'fields': ('active', 'hidden', 'user_type', 'parent_flow', 'image_config', 'create_story'), + }), + ('Advanced Settings', { + 'fields': ('websocket_url',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Customize form field for languages JSONField.""" + if db_field.name == 'languages': + kwargs['help_text'] = 'Enter languages as JSON array, e.g., ["en", "hi", "kn"]' + elif db_field.name == 'websocket_url': + kwargs['help_text'] = 'Enter WebSocket route only (e.g., "ws/common/"). Do not include the full URL.' + return super().formfield_for_dbfield(db_field, request, **kwargs) diff --git a/chatbot/admin/generic_upload_admin.py b/chatbot/admin/generic_upload_admin.py new file mode 100644 index 0000000..d78ba72 --- /dev/null +++ b/chatbot/admin/generic_upload_admin.py @@ -0,0 +1,312 @@ +import json +from django.urls import path, reverse +from django.template.response import TemplateResponse +from django.http import JsonResponse + + +class BatchUploadMixin: + """ + Mixin to add batch upload functionality to any ModelAdmin + + Usage: + class YourModelAdmin(BatchUploadMixin, admin.ModelAdmin): + list_display = ['name', 'email', ...] + # Enable batch upload + enable_batch_upload = True + # Optional: customize which fields are available for import + batch_upload_fields = ['name', 'email', 'phone'] + """ + + enable_batch_upload = False + + batch_upload_fields = None + + batch_upload_exclude = [] + + def get_urls(self): + """Add batch upload URLs""" + urls = super().get_urls() + + if not self.enable_batch_upload: + return urls + + my_urls = [ + path('batch-upload/', + self.admin_site.admin_view(self.batch_upload_view), + name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_batch_upload'), + path('batch-template/', + self.admin_site.admin_view(self.batch_template_view), + name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_batch_template'), + path('batch-import/', + self.admin_site.admin_view(self.batch_import_view), + name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_batch_import'), + ] + + return my_urls + urls + + def changelist_view(self, request, extra_context=None): + """Add batch upload button to changelist""" + extra_context = extra_context or {} + + if self.enable_batch_upload: + extra_context.update({ + 'has_batch_upload': True, + 'batch_upload_url': reverse( + f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_batch_upload' + ) + }) + + return super().changelist_view(request, extra_context=extra_context) + + def batch_upload_view(self, request): + """Render batch upload page""" + from chatbot.views.admin.generic_upload_views import GenericBatchUploadView + + view = GenericBatchUploadView() + view.kwargs = { + 'app_label': self.model._meta.app_label, + 'model_name': self.model._meta.model_name + } + + # Add admin-specific context + context = view.get_context_data() + context.update({ + 'opts': self.model._meta, + 'has_view_permission': self.has_view_permission(request), + 'has_add_permission': self.has_add_permission(request), + 'has_change_permission': self.has_change_permission(request), + }) + + # Apply field restrictions if set + if self.batch_upload_fields: + model_data = json.loads(context['model_data']) + model_data['fields'] = [ + f for f in model_data['fields'] + if f['name'] in self.batch_upload_fields + ] + context['model_data'] = json.dumps(model_data) + + # Apply exclusions + if self.batch_upload_exclude: + model_data = json.loads(context['model_data']) + model_data['fields'] = [ + f for f in model_data['fields'] + if f['name'] not in self.batch_upload_exclude + ] + context['model_data'] = json.dumps(model_data) + + return TemplateResponse( + request, + 'admin/generic_batch_upload.html', + context + ) + + def batch_template_view(self, request): + """Generate template file""" + from chatbot.views.admin.generic_upload_views import GenericBatchTemplateView + + view = GenericBatchTemplateView() + return view.post( + request, + self.model._meta.app_label, + self.model._meta.model_name + ) + + def batch_import_view(self, request): + """Process batch import""" + from chatbot.views.admin.generic_upload_views import GenericBatchImportView + + view = GenericBatchImportView() + return view.post( + request, + self.model._meta.app_label, + self.model._meta.model_name + ) + + +class SmartBatchUploadMixin(BatchUploadMixin): + """ + Enhanced version with additional features: + - Auto-detect foreign key relationships + - Handle file uploads + - Custom field processors + - Performance optimizations + """ + + # Define custom field processors + field_processors = {} + + # Enable batch loading for better performance + batch_load_foreign_keys = True + + # Cache size limit (to prevent memory issues) + max_cache_size = 1000 + + def process_field_value(self, field_name, value, row_data): + """ + Override this to add custom field processing + + Example: + def process_field_value(self, field_name, value, row_data): + if field_name == 'category': + # Auto-create category if doesn't exist + cat, created = Category.objects.get_or_create(name=value) + return cat + return super().process_field_value(field_name, value, row_data) + """ + if field_name in self.field_processors: + return self.field_processors[field_name](value, row_data) + return value + + def pre_batch_import(self, request, data): + """ + Override to add pre-import validation or processing + + Example: + def pre_batch_import(self, request, data): + # Check if user has permission to import this many records + if len(data) > 1000 and not request.user.is_superuser: + raise PermissionError("Only superusers can import more than 1000 records") + """ + pass + + def post_batch_import(self, request, results): + """ + Override to add post-import actions + + Example: + def post_batch_import(self, request, results): + # Send email notification + successful = sum(1 for r in results if r['success']) + if successful > 0: + send_import_notification(request.user, successful) + """ + pass + + def batch_import_view(self, request): + """Enhanced batch import with hooks for customization""" + from chatbot.views.admin.generic_upload_views import GenericBatchImportView + import json + + if request.method == 'POST': + try: + # Parse request data + data = json.loads(request.body) + rows = data.get('data', []) + + # Call pre-import hook + self.pre_batch_import(request, rows) + + # Create view instance + view = GenericBatchImportView() + + # Enable optimizations if set + if hasattr(self, 'batch_load_foreign_keys'): + view.batch_load_foreign_keys = self.batch_load_foreign_keys + + # Process the import + response = view.post( + request, + self.model._meta.app_label, + self.model._meta.model_name + ) + + # If successful, call post-import hook + if response.status_code == 200: + response_data = json.loads(response.content) + if response_data.get('success'): + self.post_batch_import(request, response_data.get('results', [])) + + return response + + except PermissionError as e: + return JsonResponse({'error': str(e)}, status=403) + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + return JsonResponse({'error': 'Method not allowed'}, status=405) + + +class AdvancedBatchUploadMixin(SmartBatchUploadMixin): + """ + Advanced features for complex use cases: + - Custom validators + - Duplicate handling + - Update existing records + """ + + # Enable updating existing records + allow_update_existing = False + + # Fields to use for matching existing records + update_lookup_fields = [] + + # How to handle duplicates: 'skip', 'update', 'error' + duplicate_handling = 'error' + + def find_existing_record(self, model, row_data): + """ + Find existing record based on lookup fields + + Example: + update_lookup_fields = ['email'] # Will match by email + update_lookup_fields = ['name', 'company'] # Will match by both + """ + if not self.update_lookup_fields: + return None + + lookup_kwargs = {} + for field in self.update_lookup_fields: + if field in row_data and row_data[field]: + lookup_kwargs[field] = row_data[field] + + if not lookup_kwargs: + return None + + try: + return model.objects.get(**lookup_kwargs) + except model.DoesNotExist: + return None + except model.MultipleObjectsReturned: + # If multiple records match, treat as not found + return None + + def validate_row(self, row_data, row_index): + """ + Custom validation for each row + + Override this to add custom validation logic + Return tuple: (is_valid, error_message) + """ + return True, None + + def handle_duplicate(self, existing_record, new_data): + """ + Handle duplicate records based on duplicate_handling setting + + Override this for custom duplicate handling + """ + if self.duplicate_handling == 'skip': + return { + 'success': True, + 'message': 'Skipped - record already exists', + 'action': 'skipped' + } + elif self.duplicate_handling == 'update': + # Update existing record + for field, value in new_data.items(): + if not field.startswith('_'): # Skip internal fields + setattr(existing_record, field, value) + existing_record.save() + return { + 'success': True, + 'message': 'Updated existing record', + 'action': 'updated', + 'object_id': existing_record.pk + } + else: # 'error' + return { + 'success': False, + 'message': 'Duplicate record found', + 'action': 'error' + } diff --git a/chatbot/admin/i18n_admin.py b/chatbot/admin/i18n_admin.py new file mode 100644 index 0000000..af49dc9 --- /dev/null +++ b/chatbot/admin/i18n_admin.py @@ -0,0 +1,160 @@ +from django.contrib import admin +from django.shortcuts import render +from django.urls import path +from simple_history.admin import SimpleHistoryAdmin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import I18nTag, I18nTranslation +from chatbot.services.i18n_export_service import ( + get_supported_languages, + export_translations_to_cloud, +) + + +class I18nTranslationInline(admin.TabularInline): + """Inline admin for I18nTranslation to show translations within I18nTag admin.""" + model = I18nTranslation + extra = 1 + fields = ('variable_name', 'language', 'value') + ordering = ('variable_name', 'language') + + +@admin.register(I18nTag) +class I18nTagAdmin(SimpleHistoryAdmin): + """Admin interface for I18nTag model.""" + list_display = ( + 'tag_name', 'get_translation_count', 'created_at', 'updated_at' + ) + list_filter = ( + CustomAdvanceDateFilter, + ) + search_fields = ('tag_name',) + date_hierarchy = 'created_at' + ordering = ('tag_name',) + inlines = [I18nTranslationInline] + change_list_template = 'admin/i18n_change_list.html' + + fieldsets = ( + ('Tag Information', { + 'fields': ('tag_name',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + 'export-to-cloud/', + self.admin_site.admin_view(self.export_i18n_view), + name='chatbot_i18ntag_export_s3', + ), + ] + return custom_urls + urls + + def export_i18n_view(self, request): + """Handle export requests.""" + context = { + 'title': 'Export I18n Translations', + 'languages': get_supported_languages(), + 'opts': self.model._meta, + } + + if request.method == 'POST': + language = request.POST.get('language') + + if language: + from django.contrib import messages + import logging + logger = logging.getLogger('django') + + try: + # Export translations to cloud + result = export_translations_to_cloud(language) + + if result['success']: + # Log public URLs to console + print("="*80) + print("I18N TRANSLATIONS EXPORT SUCCESSFUL") + print("="*80) + + for export in result['exports']: + print(f"{export['language_name']} ({export['language_code']}): {export['public_url']}") + + print("="*80) + + # Show success message to user + messages.success( + request, + f"{result['message']}. Check console for public URLs." + ) + else: + # Show error message + messages.error( + request, + f"Export failed: {result.get('error', 'Unknown error')}" + ) + logger.error(f"Export failed: {result.get('error', 'Unknown error')}") + + except Exception as e: + error_msg = f"Unexpected error during export: {str(e)}" + messages.error(request, error_msg) + logger.error(error_msg) + import traceback + logger.error(traceback.format_exc()) + + return render(request, 'admin/i18n_export.html', context) + + def get_translation_count(self, obj): + """Display the count of translations for this tag.""" + return obj.translations.count() + get_translation_count.short_description = 'Translations' + get_translation_count.admin_order_field = 'translations__count' + + +@admin.register(I18nTranslation) +class I18nTranslationAdmin(SimpleHistoryAdmin): + """Admin interface for I18nTranslation model.""" + list_display = ( + 'tag_id', 'variable_name', 'language', 'created_at' + ) + list_filter = ( + 'language', + 'tag_id', + CustomAdvanceDateFilter + ) + search_fields = ('tag_id__tag_name', 'variable_name', 'value') + date_hierarchy = 'created_at' + ordering = ('tag_id', 'variable_name', 'language') + raw_id_fields = ('tag_id',) + + fieldsets = ( + ('Translation Information', { + 'fields': ('tag_id', 'variable_name', 'language') + }), + ('Content', { + 'fields': ('value',), + 'description': 'Enter the translated text content.' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Customize form fields.""" + if db_field.name == 'value': + kwargs['widget'] = admin.widgets.AdminTextareaWidget(attrs={'rows': 6, 'cols': 80}) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + def get_queryset(self, request): + """Optimize queryset with select_related.""" + qs = super().get_queryset(request) + return qs.select_related('tag_id') diff --git a/chatbot/admin/media_admin.py b/chatbot/admin/media_admin.py new file mode 100644 index 0000000..220a4b4 --- /dev/null +++ b/chatbot/admin/media_admin.py @@ -0,0 +1,342 @@ +from django.contrib import admin +from django import forms +from django.shortcuts import render +from django.http import HttpResponseRedirect +from django.contrib.admin.decorators import action +from .generic_upload_admin import BatchUploadMixin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.form.media.media_form import MediaAdminForm +from chatbot.models import Tag, Profile, TagChoices, TagSourceChoices +from chatbot.models.media_models import Media, KeyValue, MediaImage +from chatbot.models.enums import FileDisplayMode, ProfileType +from simple_history.admin import SimpleHistoryAdmin +from chatbot.utils.knowledge_service.cache_manager import GetCachedItemView +from chatbot.views.Media.extract_views import BatchMediaExtractView, BatchMediaRetryExtractView +from chatbot.views.Media.save_views import BatchMediaSaveView, BatchMediaRetrySaveView +from chatbot.views.Media.status_views import BatchMediaTaskStatusView, VectorDBTaskStatusView +from chatbot.views.Media.upload_views import BatchMediaUploadView +from chatbot.views.Media.google_drive_integration import ( + GoogleDriveIntegrationView, + GoogleDriveAuthView, + GoogleDriveCallbackView, + GoogleDriveFileImportView +) + + +class KeyValueInline(admin.TabularInline): + model = KeyValue + extra = 1 + + +class MediaImagesInline(admin.TabularInline): + model = MediaImage + extra = 1 + fk_name = 'media' + fields = ('name', 'media_type', 'page', 'width', 'height') + readonly_fields = ('created_at',) + + +@admin.register(Media) +class MediaAdmin(SimpleHistoryAdmin, admin.ModelAdmin): + form = MediaAdminForm + list_display = ( + 'file_name', 'get_title', 'media_type', 'display_mode', 'parent__name', + 'view_count', 'download_count', 'updated_at', 'created_at' + ) + list_filter = ( + CustomAdvanceDateFilter, + 'display_mode', + 'name', + 'media_type', + 'company_bot' + ) + search_fields = ('name', 'key_values__value') + actions = ['export_selected', 'change_display_mode_action'] + list_export = ('csv', 'xlsx') + inlines = [KeyValueInline, MediaImagesInline] + raw_id_fields = ('company_bot', 'parent', 'organization') + date_hierarchy = 'created_at' + readonly_fields = ('view_count', 'download_count', 'thumbnail') + ordering = ('-created_at',) + + def file_name(self, obj): + return obj.name + + file_name.short_description = "File name" + + def get_title(self, obj): + """Get TITLE from key-value pairs""" + try: + title_kv = obj.key_values.filter(key__iexact='title').first() + if title_kv and title_kv.value: + # Truncate long titles for display + title = title_kv.value + if len(title) > 50: + return f"{title[:47]}..." + return title + return "-" + except Exception: + return "-" + + get_title.short_description = 'Title' + get_title.admin_order_field = 'key_values__value' + + def get_queryset(self, request): + """Optimize queries by prefetching related objects""" + qs = super().get_queryset(request) + return qs.prefetch_related('key_values', 'tags', 'parent') + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + manual_tags = getattr(obj, '_manual_tags_to_set', []) + auto_tags = getattr(obj, '_auto_tags_to_preserve', []) + + print("manual_tags to set:", manual_tags) + print("auto_tags to preserve:", auto_tags) + + obj.tags.set(manual_tags + auto_tags) + + def get_fieldsets(self, request, obj=None): + # Check if user is a MODERATOR + is_moderator = False + try: + profile = Profile.objects.get(email=request.user.email) + is_moderator = profile.profile_type == ProfileType.MODERATOR + print("Is User Moderator: ", is_moderator) + except Profile.DoesNotExist: + is_moderator = False + + if is_moderator: + base_fields = ('name', 'organization', 'file', 'markdown_file', 'thumbnail', 'url', 'display_mode', 'description', 'extracted_text', + 'media_type', 'view_count', 'download_count') + else: + base_fields = ( + 'name', 'organization', 'file', 'markdown_file', 'thumbnail', 'url', 'display_mode', 'description', 'extracted_text', 'priority', + 'media_type', 'company_bot', 'parent', 'view_count', 'download_count' + ) + + fieldsets = [ + (None, { + 'fields': base_fields + }), + ('Manual Tags', { + 'fields': ('manual_tags',), + }), + ] + + if obj and obj.pk: + has_auto_tags = obj.tags.filter( + source_type__in=[ + TagSourceChoices.AI_EXTRACTED, + TagSourceChoices.AI_GENERATED, + ] + ).exists() + + if has_auto_tags: + fieldsets.append( + ('Auto Tags', { + 'fields': ('auto_tags',), + 'description': 'Automatically generated tags' + }) + ) + + return fieldsets + + def get_actions(self, request): + """Remove the delete selected action""" + actions = super().get_actions(request) + # if 'delete_selected' in actions: + # del actions['delete_selected'] + return actions + + @action(description="Change display mode") + def change_display_mode_action(self, request, queryset): + """ + Custom action to change display_mode for selected Media objects. + Redirects to a change form where user can select the new display_mode. + """ + # Store the selected objects in the session for processing + selected_ids = queryset.values_list('id', flat=True) + request.session['selected_media_ids'] = list(selected_ids) + + # Redirect to the display mode change view + return HttpResponseRedirect(f"{request.path}change-display-mode/") + + def change_display_mode(self, request): + """ + View to handle display mode changes for selected media objects. + """ + selected_ids = request.session.get('selected_media_ids', []) + + if not selected_ids: + self.message_user(request, "No files selected.", level="error") + return HttpResponseRedirect(request.META.get('HTTP_REFERER', '../')) + + if request.method == 'POST': + new_display_mode = request.POST.get('display_mode') + apply_to = request.POST.get('apply_to', 'selected') + + if not new_display_mode: + self.message_user(request, "Please select a display mode.", level="error") + return HttpResponseRedirect(request.path) + + # Determine which objects to update + if apply_to == 'all': + media_objects = Media.objects.all() + else: + media_objects = Media.objects.filter(id__in=selected_ids) + + # Update display_mode for selected/all objects + updated_count = media_objects.update(display_mode=new_display_mode) + + self.message_user( + request, + f"Successfully updated display mode for {updated_count} file(s) to '{new_display_mode}'." + ) + + # Clear the session + if 'selected_media_ids' in request.session: + del request.session['selected_media_ids'] + + return HttpResponseRedirect('../') + + # GET request - show the form + context = { + 'title': 'Change Display Mode', + 'selected_count': len(selected_ids), + 'display_mode_choices': FileDisplayMode.choices, + } + return render(request, 'admin/change_display_mode.html', context) + + def get_urls(self): + """Add custom URLs for batch upload and display mode change""" + from django.urls import path + urls = super().get_urls() + custom_urls = [ + path( + 'change-display-mode/', + self.admin_site.admin_view(self.change_display_mode), + name='chatbot_media_change_display_mode' + ), + path('batch-upload/', + self.admin_site.admin_view(BatchMediaUploadView.as_view()), + name='chatbot_media_batch_upload'), + path('google-drive/', + self.admin_site.admin_view(GoogleDriveIntegrationView.as_view()), + name='chatbot_media_google_drive'), + path('google-drive/auth/', + self.admin_site.admin_view(GoogleDriveAuthView.as_view()), + name='chatbot_media_google_drive_auth'), + path('google-drive/callback/', + self.admin_site.admin_view(GoogleDriveCallbackView.as_view()), + name='chatbot_media_google_drive_callback'), + path('google-drive/files/import/', + self.admin_site.admin_view(GoogleDriveFileImportView.as_view()), + name='chatbot_media_google_drive_file_import'), + path('api/batch-extract/', + self.admin_site.admin_view(BatchMediaExtractView.as_view()), + name='chatbot_media_batch_extract'), + path('api/batch-save/', + self.admin_site.admin_view(BatchMediaSaveView.as_view()), + name='chatbot_media_batch_save'), + path('api/batch-task-status/', + self.admin_site.admin_view(BatchMediaTaskStatusView.as_view()), + name='chatbot_media_task_status'), + path('api/retry-extract/', + self.admin_site.admin_view(BatchMediaRetryExtractView.as_view()), + name='chatbot_media_retry_extract'), + path('api/retry-save/', + self.admin_site.admin_view(BatchMediaRetrySaveView.as_view()), + name='chatbot_media_retry_save'), + path('api/vector-db-task-status/', + self.admin_site.admin_view(VectorDBTaskStatusView.as_view()), + name='chatbot_media_vector_db_task_status'), + path('api/get-cached-item/', + self.admin_site.admin_view(GetCachedItemView.as_view()), + name='chatbot_media_get_cached_item'), + ] + return custom_urls + urls + + def delete_queryset(self, request, queryset): + errors = [] + + for obj in queryset: + try: + obj.delete() + except Exception as e: + errors.append(f"{obj.id}: {str(e)}") + + if errors: + self.message_user( + request, + f"Some files failed to delete:\n" + "\n".join(errors), + level="error" + ) + + +class MasterTagAdminForm(forms.ModelForm): + is_theme = forms.TypedChoiceField( + choices=((False, 'False'), (True, 'True')), + coerce=lambda value: value in (True, 'True', 'true', '1', 1), + initial=False, + required=True, + label='Is theme' + ) + + class Meta: + model = Tag + fields = '__all__' + + +@admin.register(Tag) +class MasterTagAdmin(BatchUploadMixin, admin.ModelAdmin): + form = MasterTagAdminForm + list_display = ('name', 'status', 'is_theme_value', 'source_type', 'created_by', 'created_at') + list_filter = ( + CustomAdvanceDateFilter, + 'name', + 'is_theme', + 'created_by', + 'source_type' + ) + fields = ( + 'name', + 'status', + 'description', + 'is_theme', + 'icon', + 'source_type', + 'company', + 'created_by' + ) + raw_id_fields = ('created_by',) + readonly_fields = ('source_type', 'company', 'created_by') + search_fields = ('name', 'description') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + enable_batch_upload = True + batch_upload_fields = ['name', 'status', 'description', 'is_theme', 'icon', 'created_by'] + + def is_theme_value(self, obj): + return 'True' if obj.is_theme else 'False' + + is_theme_value.short_description = 'Is theme' + is_theme_value.admin_order_field = 'is_theme' + + def save_model(self, request, obj, form, change): + print("In save") + if not obj.pk: + print("obj.pk= ", obj.pk) + try: + print("request.user.email: ", request.user.email) + profile = Profile.objects.get(email=request.user.email) + print("profile found: ", profile) + except Profile.DoesNotExist: + print("Exception profile doesnot exist") + profile = None + obj.created_by = profile + obj.status = TagChoices.APPROVED + obj.source_type = TagSourceChoices.MANUAL + super().save_model(request, obj, form, change) diff --git a/chatbot/admin/pdf_template_admin.py b/chatbot/admin/pdf_template_admin.py new file mode 100644 index 0000000..2d534af --- /dev/null +++ b/chatbot/admin/pdf_template_admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import PDFTemplates + + +@admin.register(PDFTemplates) +class PDFTemplatesAdmin(SimpleHistoryAdmin): + """Admin interface for PDFTemplates model.""" + list_display = ( + 'template_name', 'user_type', 'created_at', 'updated_at', 'flow' + ) + list_filter = ( + 'user_type', + 'flow', + CustomAdvanceDateFilter + ) + search_fields = ('template_name',) + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + fieldsets = ( + ('Basic Information', { + 'fields': ('template_name', 'user_type', 'flow') + }), + ('Template Content', { + 'fields': ('template', 'constants_json'), + 'description': 'Configure the template content and constants.' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Customize form fields.""" + if db_field.name == 'template': + kwargs['widget'] = admin.widgets.AdminTextareaWidget(attrs={'rows': 20, 'cols': 100}) + elif db_field.name == 'constants_json': + kwargs['help_text'] = 'Enter constants as JSON object, e.g., {"key1": "value1", "key2": "value2"}' + return super().formfield_for_dbfield(db_field, request, **kwargs) diff --git a/chatbot/admin/profile_admin.py b/chatbot/admin/profile_admin.py new file mode 100644 index 0000000..a570b24 --- /dev/null +++ b/chatbot/admin/profile_admin.py @@ -0,0 +1,176 @@ +from django.utils.html import format_html +from import_export.admin import ExportActionMixin, ImportMixin +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from chatbot.filter.admin_filter import ProfileCompanyFilter +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import Profile, ProfileType +from chatbot.resources.resource import ProfileResource +from chatbot.models.geo_models import ProfileAddress +from chatbot.models.media_models import ProfileMedia + + +class ProfileAddressInline(admin.StackedInline): + model = ProfileAddress + exclude = ['created_at', 'updated_at'] # Exclude fields from the inline form + extra = 0 + + +class ProfileMediaInline(admin.TabularInline): + model = ProfileMedia + extra = 0 + exclude = ['base64_str', 'file'] + readonly_fields = ['public_url'] + + def public_url(self, obj): + url = obj.get_public_url() + return format_html('' % url) + + public_url.short_description = 'Public URL' + + +@admin.register(Profile) +class ProfileAdmin(ImportMixin, ExportActionMixin, SimpleHistoryAdmin): + resource_class = ProfileResource + list_display = ( + 'email', + 'first_name', + 'created_at', + 'org_associated', + 'company_spoc' + ) + list_filter = ( + CustomAdvanceDateFilter, + 'email', + 'phone', + ProfileCompanyFilter, + 'profile_type' + ) + actions = ['export_selected'] + inlines = [ProfileAddressInline, ProfileMediaInline] + search_fields = ['first_name', 'email', 'phone'] + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def get_queryset(self, request): + qs = super().get_queryset(request) + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return qs + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return qs.filter(company=profile[0].company) + else: + return qs.none() + + def get_list_filter(self, request): + return super().get_list_filter(request) + + def get_list_display(self, request): + return 'email', 'first_name', 'created_at', 'company', + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + user = request.user + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if not user.is_superuser and len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + company = profile[0].company + if 'company' in form.base_fields: + form.base_fields['company'].queryset = \ + form.base_fields['company'].queryset.filter(id=company.id) + if 'resume' in form.base_fields: + form.base_fields['resume'].queryset = \ + form.base_fields['resume'].queryset.filter(company=company) + + excluded_fields = [] + if company.slug in ['zeiss']: + excluded_fields = ['resume', 'password', 'profile_code', + 'location', 'caste', 'gender', 'product_interested', 'other_params'] + for field_name in excluded_fields: + if field_name in form.base_fields: + form.base_fields.pop(field_name) + + return form + + # Custom method to display 'product_interested' field + def sector(self, obj): + product_interested = obj.other_params.get('product_interested', []) if obj.other_params else [] + sectors = [item.get('sector', '') for item in product_interested] + return ', '.join(sectors) + sector.short_description = 'Sector' + + def product(self, obj): + product_interested = obj.other_params.get('product_interested', []) if obj.other_params else [] + products = [item.get('product', '') for item in product_interested] + return ', '.join(products) + product.short_description = 'Product' + + # Custom method to display 'other_params->>'model_name' field + def other_params_model_name(self, obj): + return obj.other_params.get('model_name', '') if obj.other_params else '' + other_params_model_name.short_description = 'Model Name' + + # Custom method to display 'other_params->>'inquiry_status' field + def other_params_inquiry_status(self, obj): + return obj.other_params.get('inquiry_status', '') if obj.other_params else '' + other_params_inquiry_status.short_description = 'Inquiry Status' + + # Custom method to display 'other_params->>'discussion_details' field + def other_params_discussion_details(self, obj): + return obj.other_params.get('discussion_details', '') if obj.other_params else '' + other_params_discussion_details.short_description = 'Discussion Details' + + # Custom method to display 'other_params->>'others_budget_planning_etc' field + def other_params_others_budget_planning(self, obj): + return obj.other_params.get('others_budget_plannning_etc', '') if obj.other_params else '' + other_params_others_budget_planning.short_description = 'Other Parameters (Budget Planning etc)' + + def other_params_present_activity(self, obj): + return obj.other_params.get('present_activity', '') if obj.other_params else '' + other_params_present_activity.short_description = 'Present Activity' + + def other_params_price_offered(self, obj): + return obj.other_params.get('price_offered', '') if obj.other_params else '' + other_params_price_offered.short_description = 'Price Offered' + + def other_params_action_to_be_taken(self, obj): + return obj.other_params.get('action_to_be_taken', '') if obj.other_params else '' + other_params_action_to_be_taken.short_description = 'Action to be Taken' + + def other_params_remarks(self, obj): + return obj.other_params.get('remarks', '') if obj.other_params else '' + other_params_remarks.short_description = 'Remarks' + + def state(self, obj): + profile_address = ProfileAddress.objects.filter(profile=obj) + if len(profile_address) > 0 and profile_address[0].state: + return profile_address[0].state + else: + return '' + state.short_description = 'State' + + def city(self, obj): + profile_address = ProfileAddress.objects.filter(profile=obj) + if len(profile_address) > 0 and profile_address[0].city: + return profile_address[0].city + else: + return '' + city.short_description = 'City' + + def business_card(self, obj): + profile_media = ProfileMedia.objects.filter(profile=obj) + if len(profile_media) > 0: + url = profile_media[0].get_public_url() + return format_html('' % url) + business_card.short_description = 'Business Card' + + def get_search_results(self, request, queryset, search_term): + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).first() + if not request.user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR: + if profile.company: + queryset = queryset.filter(company=profile.company) + return queryset, use_distinct diff --git a/chatbot/admin/story_admin.py b/chatbot/admin/story_admin.py new file mode 100644 index 0000000..c5967c3 --- /dev/null +++ b/chatbot/admin/story_admin.py @@ -0,0 +1,183 @@ +from django.utils.html import format_html +from django.contrib import admin +from django.db.models import Q +from chatbot.filter.admin_filter import StoryCompanyFilter, StoryStateFilter, StoryDistrictFilter, StoryBlockFilter +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.filter.flow_filter import FlowFilter +from chatbot.filter.story_filter import UserNameFilter +from chatbot.models import StoryTag, StoryMedia, Story, Profile, ProfileType, MediaTypeChoices, StoryTranslation +from chatbot.models.geo_models import ProfileAddress +from chatbot.resources.story_resource import ( + redirect_to_export_view, generate_csv_response, generate_xls_response, generate_docx_response, + get_story_fields, get_story_data, generate_zip_response +) +from chatbot.utils.shikshalokam_story_utils import update_story_pdf, save_shikshalokam_story +from chatbot.views.admin.post_processing_views import PostProcessingView +from django.urls import path +from django.shortcuts import render +import tablib + + +class StoryTagInline(admin.TabularInline): + model = StoryTag + exclude = ['created_by'] + extra = 1 # Number of empty forms to display for adding new tags + + +class StoryMediaInline(admin.TabularInline): + model = StoryMedia + exclude = ['base64_str', 'file'] + extra = 0 + + def public_url(self, obj): + url = obj.get_public_url() + if obj.media_type == MediaTypeChoices.PDF: + return url + return format_html('' % url) + + public_url.short_description = 'Public URL' + + readonly_fields = ['public_url'] + + +@admin.register(Story) +class StoryAdmin(admin.ModelAdmin): + list_display = ('title', 'user_name_from_other_params', 'author', 'session', 'state', 'district', 'created_at',) + list_filter = ( + CustomAdvanceDateFilter, + StoryCompanyFilter, + 'author', + 'session', + StoryStateFilter, + StoryDistrictFilter, + StoryBlockFilter, + UserNameFilter, + FlowFilter, + ) + list_editable = ('state', 'district') + search_fields = ('title', 'session',) + readonly_fields = ('created_at',) + exclude = ('formatted_content', ) + inlines = [StoryTagInline, StoryMediaInline] + list_per_page = 20 + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def user_name_from_other_params(self, obj): + return obj.other_params.get('user_name') if obj.other_params else '' + + user_name_from_other_params.short_description = 'User Name' + + def get_queryset(self, request): + qs = super().get_queryset(request).select_related('author').defer('formatted_content') + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).first() + if request.user.is_superuser: + return qs + elif profile and profile.profile_type == ProfileType.MODERATOR: + profile_address = ProfileAddress.objects.filter(profile=profile).first() + print("profile_address: ", profile_address) + query = Q(author__company=profile.company) + print("Query: ", query) + if profile_address and profile_address.district: + query &= Q(author__profile__profile_address__district=profile_address.district) + if profile_address and profile_address.state: + query &= Q(author__profile__profile_address__state=profile_address.state) + results = qs.filter(query) + print("Filtered results:", results) + return results + # return qs.filter(query) + else: + return qs.none() + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + print(f"Story saved: {obj.title}") + flow = obj.other_params.get('flow') if obj.other_params else None + story_pdf_exists = StoryMedia.objects.filter( + story=obj, + media_type=MediaTypeChoices.PDF + ).exists() + if story_pdf_exists: + # Update existing PDF + print(f"Updating existing PDF for story: {obj.title}") + update_story_pdf( + access_token=None, session=obj.session, flow=flow, is_edit_story=False + ) + else: + # Create new PDF + print(f"Creating new PDF for story: {obj.title}") + save_shikshalokam_story( + story=obj, profile=obj.author, + problem_statement=None, chat_history=None, access_token=None, + project_id=None, session=obj.session, conversation=None, flow=flow + ) + + actions = [redirect_to_export_view] + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('export_stories/', self.admin_site.admin_view(self.export_stories_view), name='export_stories'), + path('post_processing/', self.admin_site.admin_view(PostProcessingView.as_view()), name='chatbot_story_post_processing'), + ] + return custom_urls + urls + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context['show_post_processing_button'] = True + return super().changelist_view(request, extra_context=extra_context) + + def export_stories_view(self, request): + ids = request.GET.get('ids', '') + selected_ids = ids.split(',') if ids else [] + stories = Story.objects.filter(id__in=selected_ids) + + if request.method == 'POST': + export_format = request.POST.get('format') + dataset = tablib.Dataset() + fields_to_export = [ + "id", "title", "author", "content", "blurb", "objective", "action_steps", "impact", + "location", "language", "stage", "created_at", "organisation" + ] + # fields_to_export=[] + headers = get_story_fields(stories, fields_to_export) + dataset.headers = headers + for story in stories: + dataset.append(get_story_data(story, headers)) + + if export_format == 'csv': + return generate_csv_response(dataset) + elif export_format == 'xls': + return generate_xls_response(dataset) + elif export_format == 'docx': + return generate_docx_response(stories, fields_to_export) + elif export_format == 'zip-pdf': + return generate_zip_response(stories) + + + return render(request, 'admin/export_story_format.html', {'ids': ids}) + + +@admin.register(StoryTranslation) +class StoryTranslationAdmin(admin.ModelAdmin): + list_display = ('story', 'language', 'story_session', 'created_at') + list_filter = ( + CustomAdvanceDateFilter, + 'language', + 'story', + 'story__session' + ) + search_fields = ('story__title', 'story__session', 'title') + readonly_fields = ('created_at',) + ordering = ('-created_at', 'story__session', 'language') + exclude = ('formatted_content', ) + raw_id_fields = ('story',) + list_per_page = 20 + date_hierarchy = 'created_at' + + def story_session(self, obj): + """Display session from related story""" + return obj.story.session if obj.story else '-' + + story_session.short_description = 'Session' diff --git a/chatbot/admin/theme_admin.py b/chatbot/admin/theme_admin.py new file mode 100644 index 0000000..571a2fe --- /dev/null +++ b/chatbot/admin/theme_admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from chatbot.models import Theme, ThemeType +# from rangefilter.filters import DateTimeRangeFilter + + +@admin.register(Theme) +class ThemeAdmin(SimpleHistoryAdmin): + list_display = ('bot', 'theme_type', 'created_at', 'updated_at') + list_filter = ( + CustomAdvanceDateFilter, + # ('updated_at', DateTimeRangeFilter), + 'bot', + 'theme_type' + ) + search_fields = ('bot__name', 'themes') + raw_id_fields = ('bot', 'master_theme') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + # Remove fields based on theme_type + if obj: + if obj.theme_type == ThemeType.MASTER: + # Hide 'themes' field + form.base_fields.pop('themes', None) + else: + # Hide 'master_theme' field + form.base_fields.pop('master_theme', None) + else: + # On add form, hide 'master_theme' initially + form.base_fields.pop('master_theme', None) + return form + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + # Optionally, adjust behavior dynamically if needed + return super().changeform_view(request, object_id, form_url, extra_context) diff --git a/chatbot/apps.py b/chatbot/apps.py new file mode 100644 index 0000000..6c6cc82 --- /dev/null +++ b/chatbot/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatbotConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chatbot' diff --git a/chatbot/auth.py b/chatbot/auth.py new file mode 100644 index 0000000..620afc6 --- /dev/null +++ b/chatbot/auth.py @@ -0,0 +1,58 @@ +import traceback +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.apps import apps as django_apps +from django.utils.translation import gettext_lazy as _ +from rest_framework_simplejwt.exceptions import InvalidToken +from rest_framework_simplejwt.settings import api_settings +from chatbot.models.auth_models import BlacklistedToken + + +class ProfileJWTAuthentication(JWTAuthentication): + + def __init__(self, *args, **kwargs): + super().__init__() + self.user_model = django_apps.get_model('chatbot.Profile', require_ready=False) + + def get_user(self, validated_token): + """ + Attempts to find and return a user using the given validated token. + """ + try: + user_id = validated_token[api_settings.USER_ID_CLAIM] + except KeyError: + raise InvalidToken(_("Token contained no recognizable user identification")) + + try: + user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except self.user_model.DoesNotExist: + raise AuthenticationFailed(_("User not found"), code="user_not_found") + + return user + + def authenticate(self, request): + try: + if 'Authorization' not in request.headers: + raise AuthenticationFailed('Token not found') + + authentication = super().authenticate(request) + print(authentication) + if authentication: + token = authentication[1] + print(token) + # Check if the token is blacklisted + if BlacklistedToken.objects.filter(token=token).exists(): + print('Token blacklisted') + raise AuthenticationFailed('Invalid token.') + + return authentication + else: + print('Token not authenticated') + except AuthenticationFailed as e: + print(e) + traceback.print_exc() + raise e + except Exception as e: + print(e) + traceback.print_exc() + return None diff --git a/chatbot/celery_tasks/__init__.py b/chatbot/celery_tasks/__init__.py new file mode 100644 index 0000000..b6e690f --- /dev/null +++ b/chatbot/celery_tasks/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/chatbot/celery_tasks/chaupal_tasks.py b/chatbot/celery_tasks/chaupal_tasks.py new file mode 100644 index 0000000..bdf9f59 --- /dev/null +++ b/chatbot/celery_tasks/chaupal_tasks.py @@ -0,0 +1,20 @@ +from celery import shared_task + +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + +logger = logging.getLogger('django') + + +@shared_task +def get_chaupal_response(channel_name, session_id, profile_id, route): + """Guided guest bot task""" + bot_strategy = BotServiceFactory.create_bot_service( + bot_type='guest_discussion', route='/shikshalokam_chaupal' + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/celery_tasks/common_chat_tasks.py b/chatbot/celery_tasks/common_chat_tasks.py new file mode 100644 index 0000000..6cfa31c --- /dev/null +++ b/chatbot/celery_tasks/common_chat_tasks.py @@ -0,0 +1,94 @@ +import base64 +from django.core.files.base import ContentFile +from django.utils.timezone import now +from celery import shared_task +from channels.layers import get_channel_layer +from chatbot.models import CompanyChat, Profile, CompanyBot +from chatbot.models.geo_models import ProfileAddress + + +channel_layer = get_channel_layer() + + +@shared_task +def save_in_company_db( + session_id, profile_id, initiated_by, message, chunks, status, translated_message=None, audio_base64=None, + stage=None, other_params=None +): + if initiated_by == 'AI': + receiver = Profile.objects.filter(id=profile_id).first() + sender = Profile.objects.get(id=1) + else: + sender = Profile.objects.filter(id=profile_id).first() + receiver = Profile.objects.get(id=1) + + last_chat = CompanyChat.objects.filter(session=session_id).order_by('-created_at').first() + print(last_chat) + + # if audio_base64: + # audio_file = base64_to_audio_file(base64_string=audio_base64, session_id=session_id) + # else: + # audio_file=None + + if last_chat and last_chat.sender == sender: + last_chat.message = message + last_chat.translated_message = translated_message + last_chat.chunks = chunks + last_chat.status = status + if audio_base64: + if last_chat.file_url: + last_chat.file_url = f"{last_chat.file_url},{audio_base64}" + else: + last_chat.file_url = audio_base64 + + last_chat.stage = stage + last_chat.other_params = other_params + last_chat.save() + else: + company_chat = CompanyChat( + message=message, + translated_message=translated_message, + chunks=chunks, + sender=sender, + receiver=receiver, + session=session_id, + status=status, + file_url=audio_base64, + stage=stage, + other_params=other_params + ) + company_chat.save() + + +@shared_task +def get_company_bot(profile, route): + company = profile.company + print(company.slug) + company_bot = CompanyBot.objects.filter(company=company).order_by('created_at') + print(company_bot) + if company.slug == 'shikshalokam': + if route == 'testimonial': + return company_bot[2] + profile_address = ProfileAddress.objects.get(profile=profile) + state = profile_address.state + if state == 'Karnataka': + return company_bot[1] + return company_bot[0] + + +def base64_to_audio_file(base64_string, session_id): + """Convert Base64 string to Django File object""" + if not base64_string: + return None + + try: + format, audio_str = base64_string.split(";base64,") + ext = format.split("/")[-1] + audio_data = base64.b64decode(audio_str) + + filename = f"{session_id}/{int(now().timestamp())}.{ext}" + + return ContentFile(audio_data, name=filename) + except Exception as e: + print(f"Base64 conversion error: {e}") + return None diff --git a/chatbot/celery_tasks/flow_tasks.py b/chatbot/celery_tasks/flow_tasks.py new file mode 100644 index 0000000..9d905e6 --- /dev/null +++ b/chatbot/celery_tasks/flow_tasks.py @@ -0,0 +1,21 @@ +from celery import shared_task + +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + +logger = logging.getLogger('django') + + +@shared_task +def get_flow_response(channel_name, session_id, profile_id, route, bot_type, bot_route): + """Common bot task""" + print(f"bot_type is {bot_type} and bot_route is {bot_route}") + bot_strategy = BotServiceFactory.create_bot_service( + bot_type=bot_type, route=bot_route + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/celery_tasks/free_flow_tasks.py b/chatbot/celery_tasks/free_flow_tasks.py new file mode 100644 index 0000000..d306d8c --- /dev/null +++ b/chatbot/celery_tasks/free_flow_tasks.py @@ -0,0 +1,25 @@ +from celery import shared_task +from chatbot.services.free_flow.free_flow_service import FreeFlowService +import logging + +logger = logging.getLogger('django') + + +@shared_task +def get_free_flow_response(channel_name, session_id, profile_id, route, bot_route): + """ + Celery task for free-flow responses. + """ + logger.info(f"Free flow task started for session {session_id}, channel {channel_name}") + + service = FreeFlowService() + service.process_and_stream( + channel_name=channel_name, + session_id=session_id, + profile_id=profile_id, + route=route, + bot_route=bot_route + ) + + logger.info(f"Free flow task completed for session {session_id}") + return "Free flow response completed" diff --git a/chatbot/celery_tasks/guided_guest_tasks.py b/chatbot/celery_tasks/guided_guest_tasks.py new file mode 100644 index 0000000..d13ea78 --- /dev/null +++ b/chatbot/celery_tasks/guided_guest_tasks.py @@ -0,0 +1,20 @@ +from celery import shared_task +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + + +logger = logging.getLogger('django') + + +@shared_task +def get_guided_guest_response(channel_name, session_id, profile_id, route): + """Guided guest bot task""" + bot_strategy = BotServiceFactory.create_bot_service( + bot_type='guided_guest', route='/guided_guest' + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/celery_tasks/handle_message.py b/chatbot/celery_tasks/handle_message.py new file mode 100644 index 0000000..fa9766b --- /dev/null +++ b/chatbot/celery_tasks/handle_message.py @@ -0,0 +1,70 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from chatbot.models import RouteLanguageChoices, Voice, VoiceType +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging + + +channel_layer = get_channel_layer() +logger = logging.getLogger('django') + + +def translate_and_send_message( + accumulated_message, current_channel_name, current_step_number, finish_reason, route, company_bot, + extra_content=None +): + + if route != 'en' and accumulated_message and accumulated_message!= '': + # target_language_code = get_language_code_from_route(route) + logger.info(f"target_language_code date: %s", route) + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=route + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=accumulated_message, target_language=route, + source_language='en' + ) + if response.get('status') == 200: + translated_messages = response.get('content') + else: + translated_messages = accumulated_message + + async_to_sync(channel_layer.send)( + current_channel_name, + { + "type": "chat.message", + "text": { + "msg": translated_messages, + "source": "bot", + "finish_reason": finish_reason, + "step": current_step_number, + "extra_content": extra_content + }, + }, + ) + logger.info(f"Translated message: %s", translated_messages) + return translated_messages + else: + logger.info(f"Sending accumulated_message: %s", accumulated_message) + async_to_sync(channel_layer.send)( + current_channel_name, + { + "type": "chat.message", + "text": { + "msg": accumulated_message, + "source": "bot", + "finish_reason": finish_reason, + "step": current_step_number, + "extra_content": extra_content + }, + }, + ) + return None + +def get_language_code_from_route(route): + route = route.strip() + for choice in RouteLanguageChoices: + if choice.value == route: + return choice.value + return RouteLanguageChoices.ENGLISH.value diff --git a/chatbot/celery_tasks/knowledge_service/media_tasks.py b/chatbot/celery_tasks/knowledge_service/media_tasks.py new file mode 100644 index 0000000..224a3e6 --- /dev/null +++ b/chatbot/celery_tasks/knowledge_service/media_tasks.py @@ -0,0 +1,188 @@ +from celery import shared_task +import os +from chatbot.models import LLMProvider +from chatbot.utils.database_util import update_single_file, delete_single_file, upsert_single_file +import logging + +from chatbot.utils.knowledge_service.openai_vector_store.vector_store_utils import upload_file_to_openai, \ + add_file_to_vector_store, delete_file_from_vector_store + +logger = logging.getLogger('django') +S3_BASE_URL = os.getenv('S3_MEDIA_URL') + + +def prepare_vector_db_data(media_id, company_slug=None): + """Helper method to prepare data for vector DB operations""" + from chatbot.models import KeyValue, Media, Company + media = Media.objects.get(id=media_id) + kvs = KeyValue.objects.filter(media=media) + company_obj = None + if company_slug: + company_obj = Company.objects.filter(slug=company_slug).first() + company_obj = company_obj or media.organization + + metadata = { + 'source': 'file', + 'url': str(media.url) if media.url is not None else S3_BASE_URL + media.file.name, + 'markdown_url': "" if not media.markdown_file else S3_BASE_URL + media.markdown_file.name, + 'company': company_obj.slug, + 'created_at': str(media.created_at), + 'type': media.media_type, + 'priority': media.priority, + 'updated_at': str(media.updated_at) + } + + # Add file_size if file exists + if media.file: + try: + metadata['file_size'] = media.file.size + except (ValueError, AttributeError): + metadata['file_size'] = None + + # Add organization_url if organization exists + if company_obj and company_obj.url: + metadata['organization_url'] = company_obj.url + + for kv in kvs: + metadata[kv.key] = kv.value + metadata['tags'] = list(media.tags.values_list('name', flat=True)) + + with media.file.open("rb") as file: + file_content = file.read() + file_name = media.file.name.split("/")[-1] + + return media, file_name, file_content, metadata + + +@shared_task +def save_in_vector_db(media_id, company_slug=None): + print(f"Save in vector for media_id: {media_id}, company_slug: {company_slug}") + media, file_name, file_content, metadata = prepare_vector_db_data( + media_id=media_id, + company_slug=company_slug + ) + if media and media.company_bot and media.company_bot.provider == LLMProvider.OPENAI: + status_code, upload_response = upload_file_to_openai( + file_name=file_name, file_content=file_content + ) + if status_code != 200 or not upload_response: + return 500 + + file_id = upload_response.get("id") + if not file_id: + logger.error("OpenAI upload succeeded but file_id missing") + return 500 + + from chatbot.models.media_models import Media + Media.objects.filter(id=media.id).update( + external_file_id=file_id + ) + media.refresh_from_db() + + status_code, vector_response = add_file_to_vector_store( + media=media, metadata=metadata + ) + + return status_code + else: + status_code, response_text = upsert_single_file(file_name, file_content, metadata, media) + print(status_code, response_text) + return status_code + + +@shared_task +def update_in_vector_db(media_id, company_slug=None): + print('Update in vector for media_id: {}'.format(media_id)) + media, file_name, file_content, metadata = prepare_vector_db_data( + media_id=media_id, + company_slug=company_slug + ) + status_code, response_text = update_single_file(media_id, file_name, file_content, metadata, media) + print("Updated in vector DB:", status_code, response_text) + return status_code + +@shared_task +def delete_from_vector_db(media_id): + print('Deleting from vector for media_id: {}'.format(media_id)) + from chatbot.models import Media + + try: + media = Media.objects.get(id=media_id) + except Media.DoesNotExist: + return 404 + + if media.company_bot and media.company_bot.provider == LLMProvider.OPENAI: + status_code, response = delete_file_from_vector_store( + media=media + ) + + return status_code + else: + company_slug = media.organization.slug if media and media.organization else None + status_code, response_text = delete_single_file(media_id, company_slug) + print(status_code, response_text) + return status_code + + +@shared_task(bind=True, max_retries=3) +def generate_media_preview(self, media_id): + """ + Generate preview/thumbnail for uploaded media + """ + import tempfile + from chatbot.models import Media + + try: + from chatbot.utils.media_preview import ThumbnailGenerator + from django.core.files.base import ContentFile + from io import BytesIO + + media = Media.objects.get(id=media_id) + + if not media.file: + logger.info(f"No file found for media_id {media_id}") + return None + + temp_file = None + + try: + file_path = media.file.path + except (NotImplementedError, AttributeError): + logger.info(f"File is on S3, downloading for media_id {media_id}") + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(media.file.name)[1]) + media.file.open('rb') + temp_file.write(media.file.read()) + media.file.close() + temp_file.close() + file_path = temp_file.name + + thumbnail = ThumbnailGenerator.generate_thumbnail(file_path) + + if thumbnail: + buffer = BytesIO() + thumbnail.save(buffer, format='JPEG', quality=85) + buffer.seek(0) + + filename = f"thumb_{media.id}.jpg" + media.thumbnail.save(filename, ContentFile(buffer.getvalue()), save=False) + + Media.objects.filter(pk=media.id).update(thumbnail=media.thumbnail) + + logger.info(f"Successfully generated preview for media_id {media_id}") + return f"Preview generated for {media.name}" + else: + logger.info(f"Could not generate preview for media_id {media_id}") + return None + + except Media.DoesNotExist: + logger.error(f"Media with id {media_id} does not exist") + return None + except Exception as e: + logger.error(f"Error generating preview for media_id {media_id}: {str(e)}") + raise self.retry(exc=e, countdown=60) + finally: + if temp_file and os.path.exists(temp_file.name): + try: + os.unlink(temp_file.name) + except Exception as e: + logger.info(f"Could not delete temp file: {str(e)}") diff --git a/chatbot/celery_tasks/knowledge_service/tag_tasks.py b/chatbot/celery_tasks/knowledge_service/tag_tasks.py new file mode 100644 index 0000000..2b3df4b --- /dev/null +++ b/chatbot/celery_tasks/knowledge_service/tag_tasks.py @@ -0,0 +1,39 @@ +from celery import shared_task +import os +from chatbot.utils.knowledge_service.base.main import get_doc_tags_from_ai + + +@shared_task +def get_auto_extracted_data(file_path, company_bot_id=None, file_extension=None, other_data=None): + from chatbot.models import CompanyBot + + company_bot = None + if company_bot_id: + try: + company_bot = CompanyBot.objects.get(id=company_bot_id) + except CompanyBot.DoesNotExist: + pass + + extracted_data=None + try: + extracted_data = get_doc_tags_from_ai( + file=file_path, + company_bot=company_bot, + file_extension=file_extension, + other_data=other_data + ) + if extracted_data and other_data and other_data.get('original_filename'): + extracted_data['original_filename'] = other_data['original_filename'] + + except Exception as e: + # log the error if needed + print(f"[AutoTags] Error processing {file_path}: {e}") + finally: + # cleanup file no matter what + if os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as cleanup_err: + print(f"[AutoTags] Failed to remove temp file {file_path}: {cleanup_err}") + + return extracted_data \ No newline at end of file diff --git a/chatbot/celery_tasks/mitra_bedrock_tasks.py b/chatbot/celery_tasks/mitra_bedrock_tasks.py new file mode 100644 index 0000000..8b7d0b4 --- /dev/null +++ b/chatbot/celery_tasks/mitra_bedrock_tasks.py @@ -0,0 +1,109 @@ +import traceback +from celery import shared_task +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import CompanyChat, Profile, CompanyBot, ChatStatus, BotVernacular, Voice, VoiceType +import json_repair + +from chatbot.utils.story_llama_utils import translate_field + + +@shared_task +def get_mitra_bedrock_response(channel_name, session_id, profile_id, route): + print(session_id) + try: + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + profile = Profile.objects.filter(id=profile_id).first() + ai_user = Profile.objects.get(id=1) + company_bot = CompanyBot.objects.get(route='/mitra-create') + system_context = company_bot.context + + prompt_to_use = [ + { + 'text': system_context.replace("{first_name}", profile.first_name if profile else "") + } + ] + + messages=[] + for chat in company_chats: + if chat.receiver == ai_user: + user_message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + user_message = chat.translated_message + messages.append({ + 'role': 'user', + 'content': [{'text': user_message}] + }) + else: + messages.append({ + 'role': 'assistant', + "content": [{'text': chat.message}] + }) + + tool_content = company_bot.tool_context + if tool_content and isinstance(tool_content, str): + tool_content = json_repair.repair_json(tool_content, return_objects=True) + try: + response = handle_bedrock_model( + system_prompt=prompt_to_use, messages=messages, is_json_response=True, + model_name=company_bot.llm_model, temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, company_bot=company_bot + ) + message = response.get("message", "") + if message == '' and response.get("should_move_forward") == 'no': + raise + except Exception: + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=route).first() + error_message = bot_vernacular.error_message if bot_vernacular.error_message else "Please try again!" + translated_message = translate_and_send_message( + accumulated_message=error_message, current_channel_name=channel_name, + current_step_number=1, finish_reason="stop", route=route, + company_bot=company_bot + ) + return translated_message + + print("response_body bedrock: ", response) + + if response: + problem_statement = response.get("problem_statement", "") + if route != 'en' and problem_statement and problem_statement != '': + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=route + ).first() + problem_statement = translate_field( + voice_provider=voice_provider, message_body=problem_statement, target_language=route, + source_language='en' + ) + + extra_content = { + "problem_statement": problem_statement, + "should_move_forward": response.get("should_move_forward", 'no'), + "validation": response.get("validation", "") + } + end_context = company_bot.end_context + end_context = json_repair.repair_json(end_context, return_objects=True) + + if response.get("should_move_forward") == 'yes': + message = '' + elif response.get("validation") == 'NO_PROBLEM_STATEMENT': + message = end_context.get('NO_PROBLEM_STATEMENT', message) + elif response.get("validation") == 'OUT_OF_SCOPE': + message = end_context.get('OUT_OF_SCOPE', message) + + translated_message = translate_and_send_message( + accumulated_message=message, current_channel_name=channel_name, + current_step_number=1, finish_reason="stop", route=route, + extra_content=extra_content, company_bot=company_bot + ) + if not message or not str(message).strip(): + message = "Understood." + + save_in_company_db( + session_id, profile_id, 'AI', message, None, ChatStatus.IN_PROGRESS, + translated_message + ) + return response + except Exception as e: + print(e) + traceback.print_exc() diff --git a/chatbot/celery_tasks/one_shot_bedrock_tasks.py b/chatbot/celery_tasks/one_shot_bedrock_tasks.py new file mode 100644 index 0000000..43a569f --- /dev/null +++ b/chatbot/celery_tasks/one_shot_bedrock_tasks.py @@ -0,0 +1,24 @@ +from celery import shared_task +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + + +logger = logging.getLogger('django') + + +@shared_task +def get_one_shot_bedrock_response(channel_name, session_id, profile_id, route): + """One-shot bot task""" + extra_params = { + 'assistant_route': '/oneshot_assistant', + 'validator_route': '/oneshot_validator' + } + bot_strategy = BotServiceFactory.create_bot_service( + bot_type='oneshot', route='/oneshot_bot', extra_params=extra_params + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/celery_tasks/oneshot_guest_tasks.py b/chatbot/celery_tasks/oneshot_guest_tasks.py new file mode 100644 index 0000000..3528f40 --- /dev/null +++ b/chatbot/celery_tasks/oneshot_guest_tasks.py @@ -0,0 +1,23 @@ +from celery import shared_task +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + + +logger = logging.getLogger('django') + +@shared_task +def get_oneshot_guest_response(channel_name, session_id, profile_id, route): + """One-shot bot task""" + extra_params = { + 'assistant_route': '/oneshot_guest_assistant', + 'validator_route': '/oneshot_guest_validator' + } + bot_strategy = BotServiceFactory.create_bot_service( + bot_type='oneshot', route='/oneshot_guest', extra_params=extra_params + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/celery_tasks/post_processing_tasks.py b/chatbot/celery_tasks/post_processing_tasks.py new file mode 100644 index 0000000..3ad47a7 --- /dev/null +++ b/chatbot/celery_tasks/post_processing_tasks.py @@ -0,0 +1,164 @@ +""" +Celery Tasks for Post Processing + +This module contains Celery tasks for running post-processing operations asynchronously. +""" +import json +import traceback +from celery import shared_task +from typing import Dict, Any, Optional +from chatbot.utils.shiksha_chaupal.iterative_solution_processor import run_iterative_solution_filtering + + +@shared_task(bind=True) +def run_unique_challenges_task(self, config: Dict[str, Any], input_file_content: Optional[str] = None): + """ + Celery task to run unique challenges processing asynchronously. + """ + try: + print(f"\n{'='*60}") + print(f"🚀 CELERY TASK STARTED: run_unique_challenges_task") + print(f"📋 Config received: {config}") + + from chatbot.utils.shiksha_chaupal.iterative_challenge_processor import run_iterative_challenge_filtering + + # Prepare input data + input_data = None + + if input_file_content: + try: + # Parse the JSON content + parsed_data = json.loads(input_file_content) + + # Normalize the data + if isinstance(parsed_data, list): + if all(isinstance(item, dict) and 'challenge' in item for item in parsed_data): + input_data = [item['challenge'] for item in parsed_data] + else: + input_data = parsed_data + else: + input_data = [parsed_data] + + except Exception as e: + return { + 'success': False, + 'error': f'Unable to parse uploaded file content: {str(e)}' + } + + # Run iterative processing + result = run_iterative_challenge_filtering( + input_data=input_data, + input_file=None, + date_from=config.get('date_from') if config.get('has_date_range') else None, + date_till=config.get('date_till') if config.get('has_date_range') else None, + max_iterations=config.get('max_iterations', 10), + filter_threshold=config.get('filter_threshold', 10.0), + batch_size=config.get('batch_size', 100), + max_workers=config.get('max_workers', 4) + ) + + # Format and return result + if result.get('success'): + return { + 'success': True, + 'status': 'completed', + 'message': result.get('message', 'Processing completed successfully'), + 'output_file': result.get('output_file'), + 'iterations': result.get('iterations_completed'), + 'final_count': len(result.get('final_challenges', [])), + 'category_counts': result.get('category_counts', {}), + 'stats': result.get('stats', []) + } + else: + return { + 'success': False, + 'status': 'failed', + 'error': result.get('message', 'Processing failed') + } + + except Exception as e: + # Log error and return failure result + print(f"❌ Error in run_unique_challenges_task: {str(e)}") + traceback.print_exc() + + return { + 'success': False, + 'status': 'failed', + 'error': f'Task execution error: {str(e)}' + } + + +@shared_task(bind=True) +def run_unique_solutions_task(self, config: Dict[str, Any], input_file_content: Optional[str] = None): + """ + Celery task to run unique solutions processing asynchronously. + """ + try: + print(f"\n{'='*60}") + print(f"🚀 CELERY TASK STARTED: run_unique_solutions_task") + print(f"📋 Config received: {config}") + + # Prepare input data + input_data = None + + if input_file_content: + try: + # Parse the JSON content + parsed_data = json.loads(input_file_content) + + # Normalize the data + if isinstance(parsed_data, list): + if all(isinstance(item, dict) and 'solution' in item for item in parsed_data): + input_data = [item['solution'] for item in parsed_data] + else: + input_data = parsed_data + else: + input_data = [parsed_data] + + except Exception as e: + return { + 'success': False, + 'error': f'Unable to parse uploaded file content: {str(e)}' + } + + # Run iterative processing + result = run_iterative_solution_filtering( + input_data=input_data, + input_file=None, + date_from=config.get('date_from') if config.get('has_date_range') else None, + date_till=config.get('date_till') if config.get('has_date_range') else None, + max_iterations=config.get('max_iterations', 10), + filter_threshold=config.get('filter_threshold', 10.0), + batch_size=config.get('batch_size', 100), + max_workers=config.get('max_workers', 4) + ) + + # Format and return result + if result.get('success'): + return { + 'success': True, + 'status': 'completed', + 'message': result.get('message', 'Processing completed successfully'), + 'output_file': result.get('output_file'), + 'iterations': result.get('iterations_completed'), + 'final_count': len(result.get('final_solutions', [])), + 'category_counts': result.get('category_counts', {}), + 'stats': result.get('stats', []) + } + else: + return { + 'success': False, + 'status': 'failed', + 'error': result.get('message', 'Processing failed') + } + + except Exception as e: + # Log error and return failure result + print(f"❌ Error in run_unique_solutions_task: {str(e)}") + traceback.print_exc() + + return { + 'success': False, + 'status': 'failed', + 'error': f'Task execution error: {str(e)}' + } diff --git a/chatbot/celery_tasks/ptm_report_tasks.py b/chatbot/celery_tasks/ptm_report_tasks.py new file mode 100644 index 0000000..b71e81f --- /dev/null +++ b/chatbot/celery_tasks/ptm_report_tasks.py @@ -0,0 +1,14 @@ +from celery import shared_task +from chatbot.utils.story_utils.story_utils import create_story_object + + +@shared_task +def create_ptm_report(profile_id, session, flow, language): + id, content, error_msg = create_story_object( + profile_id=profile_id, + session=session, + access_token=None, + flow=flow, + language=language + ) + return {"id": id, "error_msg": error_msg} diff --git a/chatbot/celery_tasks/reflection_bedrock_tasks.py b/chatbot/celery_tasks/reflection_bedrock_tasks.py new file mode 100644 index 0000000..30b9d22 --- /dev/null +++ b/chatbot/celery_tasks/reflection_bedrock_tasks.py @@ -0,0 +1,70 @@ +import traceback +from celery import shared_task +from chatbot.models import CompanyChat, Profile, CompanyBot, ChatSession +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.reflection_bedrock_tool_call import get_reflection_bedrock_tool_response +from shikshalokam.models import Project +from shikshalokam.utils.project_utils import get_project_formatted_data + + +@shared_task +def get_reflection_bedrock_response(channel_name, session_id, profile_id, route, project_id): + print(session_id) + try: + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + chat_session = ChatSession.objects.get(session=session_id) + profile = Profile.objects.get(id=profile_id) + ai_user = Profile.objects.get(id=1) + company_bot = CompanyBot.objects.get(company=profile.company, route='/reflection') + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + system_context = company_bot.context + user_project = Project.objects.filter(project_id=project_id).first() + project_data = get_project_formatted_data(user_project=user_project) + + prompt_to_use = [ + { + # Use 'text' key only for system prompts + 'text': system_context + }, + { + 'text': """ + {} + + Completion Criteria: + {} + """.format(state_machine.context, state_machine.completion_criteria) + # Use 'text' key for system instructions + }, + { + 'text': f"""{company_bot.end_context}""".format(**project_data) + }, + { + 'text': company_bot.tool_context + } + ] + + messages=[] + # Bedrock wants user to initiate message first so skipping into mssg + for chat in company_chats: + if chat.receiver == ai_user: + user_message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + user_message = chat.translated_message + messages.append({ + 'role': 'user', + 'content': [{'text': user_message}] + }) + else: + messages.append({ + 'role': 'assistant', + "content": [{'text': chat.message}] + }) + response = get_reflection_bedrock_tool_response( + system_prompt=prompt_to_use, messages=messages, company_bot=company_bot, session_id=session_id, + channel_name=channel_name, route=route, profile_id=profile_id, + ) + + return response + except Exception as e: + print(e) + traceback.print_exc() diff --git a/chatbot/celery_tasks/shikshalokam_bedrock_tasks.py b/chatbot/celery_tasks/shikshalokam_bedrock_tasks.py new file mode 100644 index 0000000..6fb8d90 --- /dev/null +++ b/chatbot/celery_tasks/shikshalokam_bedrock_tasks.py @@ -0,0 +1,20 @@ +from celery import shared_task +from chatbot.services.core.bot_service_factory import BotServiceFactory +from chatbot.services.core.orchestrator import ChatOrchestrator +import logging + + +logger = logging.getLogger('django') + + +@shared_task +def get_shikshalokam_bedrock_response(channel_name, session_id, profile_id, route): + """Guided guest bot task""" + bot_strategy = BotServiceFactory.create_bot_service( + bot_type='guided_guest', route='/' + ) + orchestrator = ChatOrchestrator(bot_strategy=bot_strategy) + return orchestrator.process_chat_request( + channel_name=channel_name, session_id=session_id, profile_id=profile_id, + language=route + ) diff --git a/chatbot/constants/post_processing_constants.py b/chatbot/constants/post_processing_constants.py new file mode 100644 index 0000000..ef982a5 --- /dev/null +++ b/chatbot/constants/post_processing_constants.py @@ -0,0 +1,81 @@ +from typing import Dict, List, Any + + +# -------------- REUSABLE FIELD DEFINITIONS ------------------ + +# -------------- CATEGORY CONSTANTS ------------------ +CHALLENGE_CATEGORIES = [ + "Challenges", + "Positive Observations", + "Advocacy/Activity Logs", + "Vague/Incomplete/Nonsense", + "Redundant/Repetitive", + "Solutions" +] + +SOLUTION_CATEGORIES = [ + "Solution Proposals", + "Implemented Success", + "Challenge Restatement", + "Vague Generalization", + "Redundant/Repetitive", +] + +# Fields for iterative processing (used by unique challenges, unique solutions, etc.) +ITERATIVE_PROCESSING_FIELDS: List[Dict[str, Any]] = [ + { + 'name': 'max_workers', + 'type': 'number', + 'label': 'Max Workers', + 'default': 2, + 'min': 1, + 'max': 4, + 'help_text': 'How many parallel workers to use for processing. Keep low (1-2) to avoid Celery conflicts.' + }, + { + 'name': 'batch_size', + 'type': 'number', + 'label': 'Batch Size', + 'default': 100, + 'min': 1, + 'max': 1000, + 'help_text': 'How many items to process together in each batch.' + }, + { + 'name': 'max_iterations', + 'type': 'number', + 'label': 'Max Iterations', + 'default': 10, + 'min': 1, + 'max': 50, + 'help_text': 'The system will keep filtering duplicates until this many rounds.' + }, + { + 'name': 'filter_threshold', + 'type': 'number', + 'label': 'Filter Threshold (%)', + 'default': 10, + 'min': 1, + 'max': 100, + 'step': 0.1, + 'help_text': 'Stop processing when filtering is lower than this percentage. Lower percentage means aggressive filtering.' + }, +] + + +# -------------- PROCESSING TYPE CONFIGURATIONS ------------------ + +PROCESSING_TYPE_CONFIG: Dict[str, Dict[str, Any]] = { + 'unique_challenges': { + 'label': 'Unique Challenges', + 'template_name': 'admin/post_processing/forms/unique_challenges_form.html', + 'handler_method': '_run_unique_challenges_processing', + 'fields': ITERATIVE_PROCESSING_FIELDS + }, + 'unique_solutions': { + 'label': 'Unique Solutions', + 'template_name': 'admin/post_processing/forms/unique_solutions_form.html', + 'handler_method': '_run_unique_solutions_processing', + 'fields': ITERATIVE_PROCESSING_FIELDS + }, +} diff --git a/chatbot/constants/voice_provider_defaults.py b/chatbot/constants/voice_provider_defaults.py new file mode 100644 index 0000000..996f10b --- /dev/null +++ b/chatbot/constants/voice_provider_defaults.py @@ -0,0 +1,94 @@ +from chatbot.models.enums import VoiceProvider, VoiceType + + +VOICE_PROVIDER_DEFAULTS = { + VoiceProvider.AI4Bharat: { + VoiceType.SpeechToText: { + "chunk_duration": 10, + "serviceId": "bhashini/iitm/asr-dravidian--gpu--t4", + "samplingRate": 16000, + "preProcessors": [], + "postProcessors": [] + }, + VoiceType.TextToText: { + "serviceId": "bhashini/iiith/nmt-all" + }, + VoiceType.Transliterate: {}, + VoiceType.TextToSpeech: { + "serviceId": "Bhashini/IITM/TTS", + "samplingRate": 22050 + } + }, + + VoiceProvider.GOOGLE: { + VoiceType.SpeechToText: { + "chunk_duration": 10, + "model": "latest_long", + "location":"global", + "enable_automatic_punctuation": False, + "enable_spoken_punctuation": False, + "enable_spoken_emojis": False, + "max_alternatives": 1, + "profanity_filter": False, + "enable_word_time_offsets": False, + "boost_words": [] + }, + VoiceType.TextToText: { + "glossary_id": None, + "location": "global" + } + }, + + VoiceProvider.OPENAI_WHISPER: { + VoiceType.SpeechToText: { + "model": "whisper-1", + "response_format": "text", + "temperature": 0, + "dictionary": [], + "chunk_duration": 100 + } + }, + + VoiceProvider.SARVAM: { + VoiceType.SpeechToText: { + "chunk_duration": 10, + "model": "saaras:v3", + "mode": "transcribe", + }, + VoiceType.TextToText: { + "model": "mayura:v1", + "mode": "modern-colloquial", + "output_script": "fully-native", + "numerals_format": "native" + }, + VoiceType.TextToSpeech: { + "model": "bulbul:v3", + "speaker": "shubh", + "speech_sample_rate": 24000, + "output_audio_codec": "wav", + "pace": 1.0, + "temperature": 0.6 + }, + + VoiceType.Transliterate: { + "numerals_format": "native", + "spoken_form": True, + "spoken_form_numerals_language": "native" + } + }, + + VoiceProvider.CUSTOM_LLM: { + VoiceType.SpeechToText: {}, + VoiceType.TextToText: { + "route": "/transliterate_text" + }, + VoiceType.Transliterate: { + "route": "/transliterate_text" + }, + } +} + + +def get_provider_defaults(provider, voice_type): + provider_defaults = VOICE_PROVIDER_DEFAULTS.get(provider, {}) + return provider_defaults.get(voice_type, {}) diff --git a/chatbot/consumers/Reflection_bedrock_consumer.py b/chatbot/consumers/Reflection_bedrock_consumer.py new file mode 100644 index 0000000..dd4edc4 --- /dev/null +++ b/chatbot/consumers/Reflection_bedrock_consumer.py @@ -0,0 +1,147 @@ +import json +import os +import traceback +from django.conf import settings +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType +from chatbot.celery_tasks.reflection_bedrock_tasks import get_reflection_bedrock_response +import jwt +from chatbot.utils.audio_provider_utils import text_translate_provider +from shikshalokam.utils.project_utils import check_and_save_project +import logging + + +logger = logging.getLogger('django') +PUBLIC_KEY = os.getenv('JWT_PUBLIC_KEY') + +class ReflectionBedrockConsumer(BaseConsumer): + try: + session_id = None + profile_id = None + project_id = None + access_token = None + route = None + + def disconnect(self, code): + print('Websocket closed') + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/reflection' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + print(text_data) + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + try: + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.project_id = text_data_json.get('projectid') + self.access_token = text_data_json.get('access_token') + self.route = text_data_json.get('route') + profile = Profile.objects.get(id=self.profile_id) + check_and_save_project( + project_id=self.project_id, access_token=self.access_token, profile=profile + ) + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}") + print(f"Received project_id: {self.project_id} and access_token: {self.access_token}") + user_id = None + if self.access_token: + decoded = jwt.decode( + self.access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + + + print(decoded) + if decoded: + user_id = decoded.get('data', {}).get('id') + + print("User_id: ", user_id) + + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'language': self.route, + 'company_bot': CompanyBot.objects.get(company=profile.company, route='/reflection'), + 'session_status': ChatStatus.IN_PROGRESS, + 'project_id': self.project_id, + 'user_id': user_id, + 'session_type': ChatType.reflection + } + ) + print(cs, cs_created) + if not cs_created and cs.language != self.route: + cs.language = self.route + cs.save(update_fields=['language']) + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/reflection' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + if self.route != 'en': + profile = Profile.objects.get(id=self.profile_id) + company_bot = CompanyBot.objects.filter(company=profile.company, route='/reflection').first() + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=self.route + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=text_data_json['text'], target_language='en', + source_language=self.route + ) + if response.get('status') == 200: + translated_message = response.get('content') + else: + translated_message = text_data_json['text'] + else: + translated_message = None + save_in_company_db(self.session_id, self.profile_id, 'User', text_data_json['text'], + None, company_chat_status, translated_message) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}") + + get_reflection_bedrock_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route, self.project_id + ) + except Exception as e: + print(e) + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + def connect(self): + try: + print('Attempting to connect to websocket') + super().connect() + except Exception: + logger.error('Connect Error: %s', e, exc_info=True) + traceback.print_exc() + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/__init__.py b/chatbot/consumers/__init__.py new file mode 100644 index 0000000..bf95bd2 --- /dev/null +++ b/chatbot/consumers/__init__.py @@ -0,0 +1,2 @@ +from . import * + diff --git a/chatbot/consumers/async_base_consumer.py b/chatbot/consumers/async_base_consumer.py new file mode 100644 index 0000000..6a33fb3 --- /dev/null +++ b/chatbot/consumers/async_base_consumer.py @@ -0,0 +1,118 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from chatbot.models import ChatSession, CompanyChat, ChatStatus, Profile, CompanyBot +from chatbot.models.company_models import CompanyStateMachine +import logging +import traceback + +logger = logging.getLogger('django') + + +class AsyncBaseConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + + async def disconnect(self, code): + try: + if hasattr(self, 'session_id') and self.session_id: + session_id = self.session_id + else: + session_id = self.scope.get('cookies', {}).get('sessionid') + + if session_id: + await self.save_chat_session(session_id) + if getattr(self, 'profile_id', None) and getattr(self, 'bot_route', None): + company_chat_status = await self.determine_company_chat_status_async( + session_id=session_id, + profile_id=self.profile_id, + is_disconnected=True, + route=self.bot_route + ) + await self.update_last_chat_status_async(chat_status=company_chat_status) + except Exception as e: + traceback.print_exc() + logger.error('Receive Error: %s', e, exc_info=True) + + finally: + await self.close() + + async def receive(self, text_data): + raise NotImplementedError("receive method must be implemented in subclass") + + async def chat_message(self, event): + text = event["text"] + await self.send(text_data=json.dumps({"text": text})) + + @database_sync_to_async + def save_chat_session(self, session_id): + chat_session = ChatSession.objects.filter(session=session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=session_id) + + if hasattr(self, 'route'): + c.save_title(self.route) + else: + c.save_title() + + @database_sync_to_async + def determine_company_chat_status(self, session_id, profile_id, route, is_disconnected=False): + if not session_id: + return None + chat_session = ChatSession.objects.filter(session=session_id).first() + if not chat_session: + return None + + profile = Profile.objects.filter(id=profile_id).first() + try: + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route=route) + else: + company_bot = CompanyBot.objects.get(route=route) + + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + + existing_chats = CompanyChat.objects.filter(session=session_id) + + if existing_chats.count() == 0: + return ChatStatus.STARTED + elif state_machine and state_machine.name != 'APPRECIATION' and is_disconnected: + return ChatStatus.PAUSED + elif existing_chats.exists(): + last_chat = existing_chats.last() + if last_chat.status == ChatStatus.PAUSED: + return ChatStatus.RESUME + elif chat_session and chat_session.session_status == ChatStatus.COMPLETED: + return ChatStatus.COMPLETED + + return ChatStatus.IN_PROGRESS + except Exception as e: + logger.info('Error in determine_company_chat_status: %s', e, exc_info=True) + + return ChatStatus.PAUSED # Default safe value + + async def determine_company_chat_status_async(self, session_id, profile_id, route, is_disconnected=False): + return await self.determine_company_chat_status(session_id, profile_id, route, is_disconnected) + + @database_sync_to_async + def update_last_chat_status(self, chat_status): + if not hasattr(self, 'session_id') or not self.session_id: + return + + try: + existing_chat = CompanyChat.objects.filter(session=self.session_id).last() + if not existing_chat: + return + + if existing_chat.status != ChatStatus.COMPLETED: + existing_chat.status = chat_status + existing_chat.save() + except Exception as e: + logger.info('Error in update_last_chat_status: %s', e, exc_info=True) + + async def update_last_chat_status_async(self, chat_status): + await self.update_last_chat_status(chat_status) diff --git a/chatbot/consumers/async_bot_response_consumer.py b/chatbot/consumers/async_bot_response_consumer.py new file mode 100644 index 0000000..37a1984 --- /dev/null +++ b/chatbot/consumers/async_bot_response_consumer.py @@ -0,0 +1,229 @@ +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.oneshot_guest_tasks import get_oneshot_guest_response +from chatbot.consumers.async_base_consumer import AsyncBaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType, CompanyChat +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.utils.transliterate_utils import transliterate_text +from shikshalokam.models import Project, ProjectStatus +from chatbot.models.enums import TextConversionType +import json +import jwt +import logging + +logger = logging.getLogger('django') + + +class AsyncBotResponseConsumer(AsyncBaseConsumer): + + session_id = None + profile_id = None + project_id = None + access_token = None + route = None + company_bot = None + task_id = None + chat_type = None + bot_route = None + chat_context = None + flow = None + + def translate_message(self, message): + try: + if not self.company_bot: + return message + + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.TextToText, + language=self.route + ).first() + + if not voice_provider: + return message + + chat_session = ChatSession.objects.filter(session=self.session_id).first() + if not chat_session: + return message + + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + + company_chats = CompanyChat.objects.filter(session=self.session_id).order_by('created_at') + + if (state_machine and state_machine.text_conversion_type == TextConversionType.TRANSLITERATE and company_chats and len(company_chats)>1): + transliterate_voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.Transliterate, + language=self.route + ).first() + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=self.route, target_language='en', + message_body=message, is_sentence=True + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content) > 0: + content = content[0] + return content + else: + response = text_translate_provider( + voice_provider=voice_provider, message_body=message, + target_language='en', source_language=self.route + ) + + if response.get('status') == 200: + return response.get('content') + else: + return message + + except Exception as e: + logger.error('Translation Error: %s', e, exc_info=True) + return message + + def disconnect(self, code): + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/oneshot_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + try: + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.project_id = text_data_json.get('projectid') + self.access_token = text_data_json.get('access_token') + self.task_id = text_data_json.get('taskid') + self.route = text_data_json.get('route') + self.chat_type = text_data_json.get('chat_type') + self.bot_route = text_data_json.get('bot_route') + self.chat_context = text_data_json.get('chat_context') + self.flow = text_data_json.get('flow') + + if not self.flow: + raise Exception("Flow is required") + + if not self.bot_route: + raise Exception("Bot route is required") + + if not self.session_id: + raise Exception("Session ID is required") + + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}, projectId: {self.project_id}, taskId: {self.task_id}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route=self.bot_route) + else: + self.company_bot = CompanyBot.objects.get(route=self.bot_route) + + other_params = { + **self.chat_context, + "flow": self.flow + } + if self.task_id: + other_params['task_id'] = self.task_id + + user_id = None + if self.access_token: + decoded = jwt.decode(self.access_token, options={"verify_signature": False}) + print("Decoded Access Token: ", decoded) + if decoded: + user_id = decoded.get('data', {}).get('id') + + print("User ID: ", user_id) + + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'language': self.route, + 'company_bot': self.company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'project_id': self.project_id, + 'user_id': user_id, + 'session_type': self.chat_type, + 'other_params': other_params + } + ) + + print(cs, cs_created) + if not cs_created and cs.language != self.route: + cs.language = self.route + cs.save(update_fields=['language']) + if self.project_id: + project = Project.objects.filter(project_id=self.project_id).first() + else: + project = None + if not project and self.project_id: + print(f"Project with ID {self.project_id} not found. Creating a new one.") + project = Project.objects.create( + project_id=self.project_id, + author=profile, + project_status=ProjectStatus.STARTED, + ) + print(f"Project created with id {project.id}") + + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/oneshot_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + translated_message = None + + if self.route != 'en' and text_data_json and text_data_json.get('text') : + translated_message = self.translate_message(message=text_data_json['text']) + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + current_stage = None + if chat_session and self.company_bot: + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + if state_machine: + current_stage = state_machine.name + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_oneshot_guest_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") + self.disconnect() diff --git a/chatbot/consumers/async_chaupal_consumer.py b/chatbot/consumers/async_chaupal_consumer.py new file mode 100644 index 0000000..55edbdc --- /dev/null +++ b/chatbot/consumers/async_chaupal_consumer.py @@ -0,0 +1,242 @@ +import asyncio +import json +import traceback +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.async_base_consumer import AsyncBaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType, CompanyChat +from chatbot.celery_tasks.chaupal_tasks import get_chaupal_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging +from channels.db import database_sync_to_async + +from chatbot.utils.transliterate_utils import transliterate_text + +logger = logging.getLogger('django') + + +class AsyncShikshalokamChaupalConsumer(AsyncBaseConsumer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = None + self.profile_id = None + self.route = None + self.company_bot = None + self.background_tasks = set() + self.ip_address = None + + # async def send_ping(self): + # while True: + # await asyncio.sleep(25) # Send ping every 25 seconds + # if self.scope["type"] == "websocket": + # try: + # # Send ping frame + # await self.send({"type": "websocket.ping"}) # Empty ping + # # Or send a text ping + # # await self.send(text_data=json.dumps({"type": "ping"})) + # except Exception as e: + # print(f"Error sending ping: {e}") + # break + + async def disconnect(self, code): + try: + logger.info(f"Websocket closed with code: %s", code) + except Exception as e: + logger.error('Disconnect Error: %s', e, exc_info=True) + finally: + # Don't call self.close() here - let the parent handle that + await super().disconnect(code) + + async def receive(self, text_data): + try: + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + self.ip_address = text_data_json.get('address') + + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route') + + profile = await self.get_profile(self.profile_id) + logger.info( + f"channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route + ) + + self.company_bot = await self.get_company_bot(profile, '/shikshalokam_chaupal') + + # Create chat session asynchronously + await self.create_chat_session(self.session_id, profile, self.company_bot) + else: + company_chat_status = await self.determine_company_chat_status_async( + session_id=self.session_id, profile_id=self.profile_id, route='/shikshalokam_chaupal' + ) + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + translated_message = None + if self.route != 'en' and text_data_json and text_data_json.get('text'): + translated_message = await self.translate_message(text_data_json['text']) + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = await database_sync_to_async( + lambda: ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + )() + + current_stage = None + if chat_session and self.company_bot: + state_machine = await database_sync_to_async( + lambda: CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + )() + if state_machine: + current_stage = state_machine.name + # Use a task for database operations + await database_sync_to_async(save_in_company_db)( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + logger.info( + f"channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route + ) + + if message_type != 'authenticate': + # Start the Celery task but don't wait for it + get_chaupal_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + + except Exception as e: + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + async def connect(self): + try: + logger.info(f"Attempting to connect to websocket") + await super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + traceback.print_exc() + + @database_sync_to_async + def get_profile(self, profile_id): + if not profile_id: + return None + return Profile.objects.filter(id=profile_id).first() + + @database_sync_to_async + def get_company_bot(self, profile, route): + if profile: + return CompanyBot.objects.get(company=profile.company, route=route) + else: + return CompanyBot.objects.get(route=route) + + @database_sync_to_async + def create_chat_session(self, session_id, profile, company_bot): + step_number = 1 + if profile and profile.first_name and profile.first_name != '': + try: + challenges_step = CompanyStateMachine.objects.get( + company_bot=self.company_bot, name="CHALLENGES" + ) + step_number = challenges_step.step + except CompanyStateMachine.DoesNotExist: + step_number = 1 + cs, cs_created = ChatSession.objects.get_or_create( + session=session_id, + defaults={ + 'profile': profile, + 'current_step': step_number, + 'language': self.route, + 'company_bot': company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'session_type': ChatType.shikshaChaupal + } + ) + logger.info(f"Chatsession: %s %s", cs, cs_created) + + if not cs_created: + if cs.language != self.route: + cs.language = self.route + + other_params = cs.other_params or {} + other_params["ip_address"] = self.ip_address + + cs.other_params = other_params + + cs.save(update_fields=["language", "other_params"]) + else: + cs.other_params = {"ip_address": self.ip_address} + cs.save(update_fields=["other_params"]) + + return cs + + @database_sync_to_async + def translate_message(self, message): + try: + if not self.company_bot: + return message + + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.TextToText, + language=self.route + ).first() + + if not voice_provider: + return message + + chat_session = ChatSession.objects.filter(session=self.session_id).first() + if not chat_session: + return message + + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + + if state_machine and state_machine.name in [ + 'INTRODUCTION', 'ORGANIZATION', 'PRI_MEMBER_ATTENDANCE', + 'SCHOOL_REPRESENTATIVE_ATTENDANCE' + ]: + transliterate_voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.Transliterate, + language=self.route + ).first() + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=self.route, target_language='en', + message_body=message, is_sentence=True + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content)>0: + content = content[0] + return content + else: + response = text_translate_provider( + voice_provider=voice_provider, message_body=message, + target_language='en', source_language=self.route + ) + + if response.get('status') == 200: + return response.get('content') + else: + return message + + except Exception as e: + logger.error('Translation Error: %s', e, exc_info=True) + return message diff --git a/chatbot/consumers/async_consumer.py b/chatbot/consumers/async_consumer.py new file mode 100644 index 0000000..6a83ae6 --- /dev/null +++ b/chatbot/consumers/async_consumer.py @@ -0,0 +1,287 @@ +import json +import traceback +import os +from django.conf import settings +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.async_base_consumer import AsyncBaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType, CompanyChat, \ + TextConversionType, CompanyBotTypeChoices +from chatbot.celery_tasks.flow_tasks import get_flow_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging +from channels.db import database_sync_to_async +from chatbot.utils.transliterate_utils import transliterate_text +import jwt + +logger = logging.getLogger('django') +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + + +class AsyncSocketConsumer(AsyncBaseConsumer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = None + self.profile_id = None + self.route = None + self.bot_route = None + self.company_bot = None + self.flow_name = None + self.ip_address = None + self.access_token = None + self.background_tasks = set() + + async def disconnect(self, code): + try: + logger.info(f"Websocket closed with code: %s", code) + except Exception as e: + logger.error('Disconnect Error: %s', e, exc_info=True) + finally: + # Don't call self.close() here - let the parent handle that + await super().disconnect(code) + + async def receive(self, text_data): + try: + logger.info(f"Received text data via common websocket: {text_data}") + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + company_chat_status = None + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route') + self.bot_route = text_data_json.get('bot_route') + self.flow_name = text_data_json.get('flow_name') + self.ip_address = text_data_json.get('address') + + profile = await self.get_profile(self.profile_id) + logger.info( + f"channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route + ) + + user_id = await self.handle_access_token(self.access_token) + + self.company_bot = await self.get_company_bot(profile, self.bot_route) + + # Create chat session asynchronously + await self.create_chat_session( + self.session_id, profile, self.company_bot, self.ip_address, user_id + ) + else: + # Validate that user is authenticated before processing messages + if not self.session_id or not self.bot_route: + error_msg = "Authentication required. Please send authentication message first with type='authenticate', sessionid, profileid, route, and bot_route." + logger.error(f"Unauthenticated message attempt: {error_msg}") + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "text": { + "msg": error_msg, + "source": "system", + "error": True + }, + }, + ) + return + company_chat_status = await self.determine_company_chat_status_async( + session_id=self.session_id, profile_id=self.profile_id, route=self.bot_route + ) + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + translated_message = None + if self.route != 'en' and text_data_json and text_data_json.get('text'): + translated_message = await self.translate_message(text_data_json['text']) + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = await database_sync_to_async( + lambda: ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + )() + + current_stage = None + if chat_session and self.company_bot and self.company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + try: + state_machine = await database_sync_to_async( + lambda: CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + )() + if state_machine: + current_stage = state_machine.name + except CompanyStateMachine.DoesNotExist: + logger.error( + f"CompanyStateMachine not found for bot_id={self.company_bot.id}, " + f"step={chat_session.current_step}. " + f"Please create state machines in admin panel." + ) + # Use a task for database operations + await database_sync_to_async(save_in_company_db)( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + logger.info( + f"channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route + ) + + if message_type != 'authenticate': + # Start the Celery task but don't wait for it + get_flow_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route, + 'common', self.bot_route + ) + + except Exception as e: + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + async def connect(self): + try: + logger.info(f"Attempting to connect to websocket") + await super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + traceback.print_exc() + + @database_sync_to_async + def get_profile(self, profile_id): + if not profile_id: + return None + return Profile.objects.filter(id=profile_id).first() + + @database_sync_to_async + def handle_access_token(self, access_token): + user_id = None + + if access_token: + print("Access Token: ", access_token) + + try: + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + print("Decoded JWT: ", decoded) + if decoded: + user_id = decoded.get("data", {}).get("id") + except Exception as e: + logger.error('JWT Decode Error: %s', e, exc_info=True) + print(f"JWT Decode Error: {e}") + + logger.info("User_id: %s", user_id) + return user_id + + @database_sync_to_async + def get_company_bot(self, profile, route): + if profile: + return CompanyBot.objects.get(company=profile.company, route=route) + else: + return CompanyBot.objects.get(route=route) + + @database_sync_to_async + def create_chat_session(self, session_id, profile, company_bot, ip_address, user_id): + step_number = 1 + if profile and profile.first_name and profile.first_name != '': + try: + challenges_step = CompanyStateMachine.objects.get( + company_bot=self.company_bot, name="CHALLENGES" + ) + step_number = challenges_step.step + except CompanyStateMachine.DoesNotExist: + step_number = 1 + cs, cs_created = ChatSession.objects.get_or_create( + session=session_id, + defaults={ + 'profile': profile, + 'current_step': step_number, + 'language': self.route, + 'company_bot': company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'user_id': user_id, + 'session_type': self.flow_name + } + ) + logger.info(f"Chatsession: %s %s", cs, cs_created) + + if not cs_created: + if cs.language != self.route: + cs.language = self.route + + other_params = cs.other_params or {} + other_params["ip_address"] = ip_address + + cs.other_params = other_params + + cs.save(update_fields=["language", "other_params"]) + else: + cs.other_params = {"ip_address": ip_address} + cs.save(update_fields=["other_params"]) + + + return cs + + @database_sync_to_async + def translate_message(self, message): + try: + if not self.company_bot: + return message + + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.TextToText, + language=self.route + ).first() + + if not voice_provider: + return message + + chat_session = ChatSession.objects.filter(session=self.session_id).first() + if not chat_session: + return message + + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + + if state_machine and state_machine.text_conversion_type == TextConversionType.TRANSLITERATE: + transliterate_voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.Transliterate, + language=self.route + ).first() + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=self.route, target_language='en', + message_body=message, is_sentence=True + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content)>0: + content = content[0] + return content + else: + response = text_translate_provider( + voice_provider=voice_provider, message_body=message, + target_language='en', source_language=self.route + ) + + if response.get('status') == 200: + return response.get('content') + else: + return message + + except Exception as e: + logger.error('Translation Error: %s', e, exc_info=True) + return message diff --git a/chatbot/consumers/base_consumer.py b/chatbot/consumers/base_consumer.py new file mode 100644 index 0000000..ddb1429 --- /dev/null +++ b/chatbot/consumers/base_consumer.py @@ -0,0 +1,65 @@ +import json +from channels.generic.websocket import WebsocketConsumer +from chatbot.models import ChatSession, CompanyChat, ChatStatus, Profile, CompanyBot +from chatbot.models.company_models import CompanyStateMachine + + +class BaseConsumer(WebsocketConsumer): + def connect(self): + self.accept() + + def disconnect(self, code): + session_id = self.scope['cookies']['sessionid'] + chat_session = ChatSession.objects.filter(session=session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=session_id) + c.save_title() + self.close() + + def receive(self, text_data): + raise NotImplementedError("receive method must be implemented in subclass") + + def chat_message(self, event): + text = event["text"] + self.send(text_data=json.dumps({"text": text})) + + def determine_company_chat_status(self, session_id, profile_id, route,is_disconnected=False): + if not session_id: + return None + chat_session = ChatSession.objects.filter(session=session_id).first() + + profile = Profile.objects.filter(id=self.profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route=route) + else: + company_bot = CompanyBot.objects.get(route=route) + + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + + existing_chats = CompanyChat.objects.filter(session=session_id) + + if existing_chats.count() == 0: + return ChatStatus.STARTED + elif state_machine and state_machine.name != 'APPRECIATION' and is_disconnected: + return ChatStatus.PAUSED + elif existing_chats.exists(): + last_chat = existing_chats.last() + if last_chat.status == ChatStatus.PAUSED: + return ChatStatus.RESUME + elif chat_session and chat_session.session_status == ChatStatus.COMPLETED: + return ChatStatus.COMPLETED + + return ChatStatus.IN_PROGRESS + + def update_last_chat_status(self, chat_status): + existing_chat = CompanyChat.objects.filter(session=self.session_id).last() + if not existing_chat: + return + print("msg: ", existing_chat.message) + if existing_chat and existing_chat.status != ChatStatus.COMPLETED: + existing_chat.status = chat_status + existing_chat.save() diff --git a/chatbot/consumers/chaupal_consumer.py b/chatbot/consumers/chaupal_consumer.py new file mode 100644 index 0000000..1f13f46 --- /dev/null +++ b/chatbot/consumers/chaupal_consumer.py @@ -0,0 +1,132 @@ +import json +import traceback +from asgiref.sync import async_to_sync # Ensure asgiref is installed +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType, CompanyChat +from chatbot.celery_tasks.chaupal_tasks import get_chaupal_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging + +logger = logging.getLogger('django') + + +class ShikshalokamChaupalConsumer(BaseConsumer): + try: + session_id = None + profile_id = None + route = None + company_bot = None + + def disconnect(self, code): + print('Websocket closed') + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + print(text_data) + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + try: + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route='/shikshalokam_chaupal') + else: + self.company_bot = CompanyBot.objects.get(route='/shikshalokam_chaupal') + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'company_bot': self.company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'session_type': ChatType.shikshaChaupal + } + ) + print(cs, cs_created) + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/shikshalokam_chaupal' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + if self.route != 'en': + print("Company bot: ", self.company_bot) + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, type=VoiceType.TextToText, language=self.route + ).first() + if text_data_json and text_data_json.get('text'): + existing_chats = CompanyChat.objects.filter(session=self.session_id) + chat_session = ChatSession.objects.filter(session=self.session_id).first() + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + response = text_translate_provider( + voice_provider=voice_provider, message_body=text_data_json['text'], target_language='en', + source_language=self.route + ) + if response.get('status') == 200: + translated_message = response.get('content') + else: + translated_message = text_data_json['text'] + else: + translated_message=None + else: + translated_message = None + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio') + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_chaupal_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + print(e) + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + def connect(self): + try: + print('Attempting to connect to websocket') + super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + print(f"Connect Error: {e}") + traceback.print_exc() + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/free_flow_consumer.py b/chatbot/consumers/free_flow_consumer.py new file mode 100644 index 0000000..64d467f --- /dev/null +++ b/chatbot/consumers/free_flow_consumer.py @@ -0,0 +1,220 @@ +import json +import os +import traceback +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.async_base_consumer import AsyncBaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot +import logging +from channels.db import database_sync_to_async +import jwt +from chatbot.celery_tasks.free_flow_tasks import get_free_flow_response + +logger = logging.getLogger('django') + + +class FreeFlowConsumer(AsyncBaseConsumer): + """ + WebSocket consumer for free-flow conversation. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = None + self.profile_id = None + self.route = None + self.bot_route = None + self.company_bot = None + self.flow_name = None + self.ip_address = None + self.access_token = None + + async def disconnect(self, code): + try: + logger.info(f"Free-flow websocket closed with code: %s", code) + except Exception as e: + logger.error('Disconnect Error: %s', e, exc_info=True) + finally: + await super().disconnect(code) + + async def receive(self, text_data): + try: + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + company_chat_status = None + + if message_type == 'authenticate': + # Handle authentication + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route', 'en') + self.bot_route = text_data_json.get('bot_route') + self.flow_name = text_data_json.get('flow_name', 'free_flow') + self.ip_address = text_data_json.get('address') + self.access_token = text_data_json.get('access_token') + + profile = await self.get_profile(self.profile_id) + logger.info( + f"Free-flow channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route + ) + + user_id = await self.handle_access_token(self.access_token) + + # If an access_token was provided but verification failed, reject authentication + if self.access_token and not user_id: + logger.error( + "Authentication failed: invalid access_token for channel %s session %s", + self.channel_name, self.session_id + ) + await self.send(text_data=json.dumps({ + "text": { + "msg": "Authentication failed: invalid access token", + "source": "system", + "type": "auth_error" + } + })) + return + self.company_bot = await self.get_company_bot(profile, self.bot_route) + + # Create chat session asynchronously + await self.create_chat_session( + self.session_id, profile, self.company_bot, self.ip_address, user_id + ) + + else: + # Handle regular chat messages + user_message = text_data_json.get('text') + if not user_message: + logger.info("Received empty message") + return + + # Determine chat status + company_chat_status = await self.determine_company_chat_status_async( + session_id=self.session_id, profile_id=self.profile_id, route=self.bot_route + ) + + # Echo user message back + await self.send(text_data=json.dumps({ + "text": { + "msg": user_message, + "source": "user" + } + })) + + # Save user message to database + await database_sync_to_async(save_in_company_db)( + session_id=self.session_id, + profile_id=self.profile_id, + initiated_by='User', + message=user_message, + chunks=None, + status=company_chat_status, + translated_message=None, + audio_base64=text_data_json.get('asr_audio') + ) + + logger.info( + f"Processing free-flow message - channel_name: %s, session_id: %s", + self.channel_name, self.session_id + ) + + # Launch Celery task for streaming (FIRE AND FORGET) + get_free_flow_response.delay( + self.channel_name, + self.session_id, + self.profile_id, + self.route, + self.bot_route + ) + + except Exception as e: + logger.error('Receive Error: %s', e, exc_info=True) + await self.send(text_data=json.dumps({ + "text": { + "msg": "An error occurred processing your message", + "source": "system", + "type": "error" + } + })) + + async def connect(self): + try: + logger.info(f"Attempting to connect to free-flow websocket") + await super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + + + + @database_sync_to_async + def get_profile(self, profile_id): + if not profile_id: + return None + return Profile.objects.filter(id=profile_id).first() + + @database_sync_to_async + def handle_access_token(self, access_token): + user_id = None + try: + if access_token: + # Verify with signature using RS256 when access_token is provided + PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + logger.info("Decoded JWT: %s", decoded) + + if decoded: + user_id = decoded.get('data', {}).get('id') + + except jwt.ExpiredSignatureError: + logger.error('Access token has expired', exc_info=True) + except jwt.InvalidTokenError as e: + logger.error('Invalid access token: %s', e, exc_info=True) + except Exception as e: + logger.error('Access token Decode Error: %s', e, exc_info=True) + + logger.info("User_id: %s", user_id) + return user_id + + @database_sync_to_async + def get_company_bot(self, profile, route): + if profile: + return CompanyBot.objects.get(company=profile.company, route=route) + else: + return CompanyBot.objects.get(route=route) + + @database_sync_to_async + def create_chat_session(self, session_id, profile, company_bot, ip_address, user_id): + cs, cs_created = ChatSession.objects.get_or_create( + session=session_id, + defaults={ + 'profile': profile, + 'current_step': 1, # Not used in free-flow but required + 'language': self.route, + 'company_bot': company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'user_id': user_id, + 'session_type': self.flow_name + } + ) + logger.info(f"Chat session for free-flow: %s %s", cs, cs_created) + + if not cs_created: + if cs.language != self.route: + cs.language = self.route + + other_params = cs.other_params or {} + other_params["ip_address"] = ip_address + cs.other_params = other_params + cs.save(update_fields=["language", "other_params"]) + else: + cs.other_params = {"ip_address": ip_address} + cs.save(update_fields=["other_params"]) + + return cs + + diff --git a/chatbot/consumers/guided_guest_consumer.py b/chatbot/consumers/guided_guest_consumer.py new file mode 100644 index 0000000..29f6cfa --- /dev/null +++ b/chatbot/consumers/guided_guest_consumer.py @@ -0,0 +1,232 @@ +import json +import traceback +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.celery_tasks.guided_guest_tasks import get_guided_guest_response +import logging + +from chatbot.utils.transliterate_utils import transliterate_text +from shikshalokam.models import Project, ProjectStatus + +logger = logging.getLogger('django') + + +class GuidedGuestConsumer(BaseConsumer): + try: + session_id = None + profile_id = None + route = None + company_bot = None + project_id = None + task_id = None + ip_address = None + + def translate_message(self, message): + try: + if not self.company_bot: + return message + + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.TextToText, + language=self.route + ).first() + + if not voice_provider: + return message + + chat_session = ChatSession.objects.filter(session=self.session_id).first() + if not chat_session: + return message + + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + + if state_machine and state_machine.name in [ + 'INTRODUCTION', 'ROLE_INSTITUTE', 'FEDERATION_DETAILS', 'OCCUPATION', 'IMPLEMENTATION_LOCATION' + ]: + transliterate_voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.Transliterate, + language=self.route + ).first() + is_sentence = ' ' in message + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=self.route, target_language='en', + message_body=message, is_sentence=is_sentence + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content) > 0: + content = content[0] + return content + else: + response = text_translate_provider( + voice_provider=voice_provider, message_body=message, + target_language='en', source_language=self.route + ) + + if response.get('status') == 200: + return response.get('content') + else: + return message + + except Exception as e: + logger.error('Translation Error: %s', e, exc_info=True) + return message + + def disconnect(self, code): + print('Websocket closed') + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/guided_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + print(text_data) + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + company_chat_status = None + + try: + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route') + self.task_id = text_data_json.get('taskid') + self.project_id = text_data_json.get('projectid') + self.ip_address = text_data_json.get('address') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}, projectId: {self.project_id}, taskId: {self.task_id}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route='/guided_guest') + else: + self.company_bot = CompanyBot.objects.get(route='/guided_guest') + if self.task_id: + other_params = { + "task_id": self.task_id + } + else: + other_params = {} + # chat session create (session, profile) + step_number = 1 + if profile and profile.first_name and profile.first_name != '': + try: + educational_step = CompanyStateMachine.objects.get( + company_bot=self.company_bot, name="EDUCATIONAL_PROBLEMS" + ) + step_number = educational_step.step + except CompanyStateMachine.DoesNotExist: + step_number = 1 + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': step_number, + 'language': self.route, + 'company_bot': self.company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'session_type': ChatType.guidedReflection, + 'project_id': self.project_id, + 'other_params': other_params + } + ) + print(cs, cs_created) + + if not cs_created: + if cs.language != self.route: + cs.language = self.route + + other_params = cs.other_params or {} + other_params["ip_address"] = self.ip_address + + cs.other_params = other_params + + cs.save(update_fields=["language", "other_params"]) + else: + cs.other_params = {"ip_address": self.ip_address} + cs.save(update_fields=["other_params"]) + + project = Project.objects.filter(project_id=self.project_id).first() + if not project: + print(f"Project with ID {self.project_id} not found. Creating a new one.") + project = Project.objects.create( + project_id=self.project_id, + author=profile, + project_status=ProjectStatus.STARTED, + ) + print(f"Project created with id {project.id}") + else: + print(f"Found existing project: {project.id}") + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/guided_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + translated_message = None + if self.route != 'en' and text_data_json and text_data_json.get('text'): + translated_message = self.translate_message(message=text_data_json['text']) + + print("text_data_json: ", text_data_json) + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + current_stage=None + if chat_session and self.company_bot: + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + if state_machine: + current_stage=state_machine.name + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_guided_guest_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + print(e) + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + def connect(self): + try: + print('Attempting to connect to websocket') + super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + print(f"Connect Error: {e}") + traceback.print_exc() + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/mitra_bedrock_consumer.py b/chatbot/consumers/mitra_bedrock_consumer.py new file mode 100644 index 0000000..d7f503c --- /dev/null +++ b/chatbot/consumers/mitra_bedrock_consumer.py @@ -0,0 +1,152 @@ +import json +import os +import traceback +from django.conf import settings +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType +import jwt +from chatbot.celery_tasks.mitra_bedrock_tasks import get_mitra_bedrock_response +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging + + +logger = logging.getLogger('django') +PUBLIC_KEY = os.getenv('JWT_PUBLIC_KEY') + +class MitraBedrockConsumer(BaseConsumer): + try: + session_id = None + profile_id = None + access_token = None + route = None + + def disconnect(self, code): + print('Websocket closed') + logger.info('Websocket closed') + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/mitra-create' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + print(text_data) + logger.info('Received text_data: %s', text_data) + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + try: + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.access_token = text_data_json.get('access_token') + self.route = text_data_json.get('route') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}") + logger.info("Authenticated with session_id: %s, profile_id: %s, route: %s", + self.session_id, self.profile_id, self.route) + print(f"Received access_token: {self.access_token}") + user_id = None + if self.access_token: + try: + decoded = jwt.decode( + self.access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + user_id = decoded.get('data', {}).get('id') + except jwt.ExpiredSignatureError: + logger.error("JWT token expired") + user_id = None + except jwt.InvalidTokenError: + logger.error("Invalid JWT token") + user_id = None + + print("User_id: ", user_id) + logger.info("User_id: %s", user_id) + + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'language': self.route, + 'company_bot': CompanyBot.objects.get(route='/mitra-create'), + 'session_status': ChatStatus.IN_PROGRESS, + 'user_id': user_id, + 'session_type': ChatType.creation + } + ) + print(cs, cs_created) + if not cs_created and cs.language != self.route: + cs.language = self.route + cs.save(update_fields=['language']) + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/mitra-create' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + if self.route != 'en': + company_bot = CompanyBot.objects.filter(route='/mitra-create').first() + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=self.route + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=text_data_json['text'], target_language='en', + source_language=self.route + ) + if response.get('status') == 200: + translated_message = response.get('content') + else: + translated_message = text_data_json['text'] + else: + translated_message = None + save_in_company_db(self.session_id, self.profile_id, 'User', text_data_json['text'], + None, company_chat_status, translated_message) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}") + logger.info("channel_name: %s, session_id: %s, profile_id: %s, route: %s", + self.channel_name, self.session_id, self.profile_id, self.route) + + self.route = self.route.strip() + + get_mitra_bedrock_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + print(e) + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + def connect(self): + try: + print('Attempting to connect to websocket') + logger.info('Attempting to connect to websocket') + super().connect() + except Exception: + logger.error('Connect Error: %s', e, exc_info=True) + traceback.print_exc() + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/one_shot_bedrock_consumer.py b/chatbot/consumers/one_shot_bedrock_consumer.py new file mode 100644 index 0000000..ed7ba96 --- /dev/null +++ b/chatbot/consumers/one_shot_bedrock_consumer.py @@ -0,0 +1,170 @@ +import json +import os +from django.conf import settings +import traceback +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType +from chatbot.celery_tasks.one_shot_bedrock_tasks import get_one_shot_bedrock_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import jwt +import logging + +from shikshalokam.models import Project, ProjectStatus + +logger = logging.getLogger('django') +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + + +class OneShotBedrockConsumer(BaseConsumer): + + try: + session_id = None + profile_id = None + project_id = None + access_token = None + route = None + company_bot = None + task_id = None + + def disconnect(self, code): + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/oneshot_bot' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.project_id = text_data_json.get('projectid') + self.access_token = text_data_json.get('access_token') + self.route = text_data_json.get('route') + self.task_id = text_data_json.get('taskid') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}, projectId: {self.project_id}, taskId: {self.task_id}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route='/oneshot_bot') + else: + self.company_bot = CompanyBot.objects.get(route='/oneshot_bot') + + if self.task_id: + other_params = { + "task_id": self.task_id + } + else: + other_params = {} + + user_id = None + + if self.access_token: + decoded = jwt.decode( + self.access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + print(decoded) + if decoded: + user_id = decoded.get("data", {}).get("id") + + print("User_id: ", user_id) + + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'language': self.route, + 'company_bot': self.company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'project_id': self.project_id, + 'user_id': user_id, + 'session_type': ChatType.oneStepReflection, + 'other_params': other_params + } + ) + print(cs, cs_created) + if not cs_created and cs.language != self.route: + cs.language = self.route + cs.save(update_fields=['language']) + project = Project.objects.filter(project_id=self.project_id).first() + if not project: + print(f"Project with ID {self.project_id} not found. Creating a new one.") + project = Project.objects.create( + project_id=self.project_id, + author=profile, + project_status=ProjectStatus.STARTED, + ) + print(f"Project created with id {project.id}") + else: + print(f"Found existing project: {project.id}") + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/oneshot_bot' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + if self.route != 'en' and text_data_json and text_data_json.get('text'): + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, type=VoiceType.TextToText, language=self.route + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=text_data_json['text'], target_language='en', + source_language=self.route + ) + if response.get('status') == 200: + translated_message = response.get('content') + else: + translated_message = text_data_json['text'] + else: + translated_message = None + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + current_stage = None + if chat_session and self.company_bot: + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + if state_machine: + current_stage = state_machine.name + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_one_shot_bedrock_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/oneshot_guest_consumer.py b/chatbot/consumers/oneshot_guest_consumer.py new file mode 100644 index 0000000..233eab6 --- /dev/null +++ b/chatbot/consumers/oneshot_guest_consumer.py @@ -0,0 +1,216 @@ +import json +import os +from django.conf import settings +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType, CompanyChat +from chatbot.celery_tasks.oneshot_guest_tasks import get_oneshot_guest_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import jwt +import logging + +from chatbot.utils.transliterate_utils import transliterate_text +from shikshalokam.models import Project, ProjectStatus + +logger = logging.getLogger('django') +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + +class OneShotGuestConsumer(BaseConsumer): + + try: + session_id = None + profile_id = None + project_id = None + access_token = None + route = None + company_bot = None + task_id = None + + def translate_message(self, message): + try: + if not self.company_bot: + return message + + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.TextToText, + language=self.route + ).first() + + if not voice_provider: + return message + + chat_session = ChatSession.objects.filter(session=self.session_id).first() + if not chat_session: + return message + + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + + company_chats = CompanyChat.objects.filter(session=self.session_id).order_by('created_at') + + if (state_machine and state_machine.name in ['INTRODUCTION', 'ORGANIZATION', 'DESIGNATION'] and + company_chats and len(company_chats)>1): + transliterate_voice_provider = Voice.objects.filter( + company_bot=self.company_bot, + type=VoiceType.Transliterate, + language=self.route + ).first() + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=self.route, target_language='en', + message_body=message, is_sentence=True + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content) > 0: + content = content[0] + return content + else: + response = text_translate_provider( + voice_provider=voice_provider, message_body=message, + target_language='en', source_language=self.route + ) + + if response.get('status') == 200: + return response.get('content') + else: + return message + + except Exception as e: + logger.error('Translation Error: %s', e, exc_info=True) + return message + + def disconnect(self, code): + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/oneshot_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.project_id = text_data_json.get('projectid') + self.access_token = text_data_json.get('access_token') + self.task_id = text_data_json.get('taskid') + self.route = text_data_json.get('route') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}, projectId: {self.project_id}, taskId: {self.task_id}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route='/oneshot_guest') + else: + self.company_bot = CompanyBot.objects.get(route='/oneshot_guest') + + if self.task_id: + other_params = { + "task_id": self.task_id + } + else: + other_params = {} + + user_id = None + if self.access_token: + decoded = jwt.decode( + self.access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + if decoded: + user_id = decoded.get("data", {}).get("id") + + print("User_id: ", user_id) + + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'language': self.route, + 'company_bot': self.company_bot, + 'session_status': ChatStatus.IN_PROGRESS, + 'project_id': self.project_id, + 'user_id': user_id, + 'session_type': ChatType.oneStepReflection, + 'other_params': other_params + } + ) + + print(cs, cs_created) + if not cs_created and cs.language != self.route: + cs.language = self.route + cs.save(update_fields=['language']) + if self.project_id: + project = Project.objects.filter(project_id=self.project_id).first() + else: + project = None + if not project and self.project_id: + print(f"Project with ID {self.project_id} not found. Creating a new one.") + project = Project.objects.create( + project_id=self.project_id, + author=profile, + project_status=ProjectStatus.STARTED, + ) + print(f"Project created with id {project.id}") + + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/oneshot_guest' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + translated_message = None + + if self.route != 'en' and text_data_json and text_data_json.get('text') : + translated_message = self.translate_message(message=text_data_json['text']) + + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + current_stage = None + if chat_session and self.company_bot: + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + if state_machine: + current_stage = state_machine.name + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_oneshot_guest_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/consumers/shikshalokam_bedrock_consumer.py b/chatbot/consumers/shikshalokam_bedrock_consumer.py new file mode 100644 index 0000000..0b1de3a --- /dev/null +++ b/chatbot/consumers/shikshalokam_bedrock_consumer.py @@ -0,0 +1,177 @@ +import json +import traceback +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.consumers.base_consumer import BaseConsumer +from chatbot.models import ChatStatus, ChatSession, Profile, CompanyBot, Voice, VoiceType, ChatType +from chatbot.celery_tasks.shikshalokam_bedrock_tasks import get_shikshalokam_bedrock_response +from chatbot.models.company_models import CompanyStateMachine +from chatbot.utils.audio_provider_utils import text_translate_provider +import logging + +from shikshalokam.models import Project, ProjectStatus + +logger = logging.getLogger('django') + + +class ShikshalokamBedrockConsumer(BaseConsumer): + try: + session_id = None + profile_id = None + route = None + company_bot = None + project_id = None + task_id = None + ip_address = None + + def disconnect(self, code): + print('Websocket closed') + chat_session = ChatSession.objects.filter(session=self.session_id) + if chat_session.exists(): + c = chat_session[0] + else: + c = ChatSession(session=self.session_id) + c.save_title(self.route) + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, is_disconnected=True, route='/' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + self.update_last_chat_status(chat_status=company_chat_status) + self.close() + + def receive(self, text_data): + print(text_data) + text_data_json = json.loads(text_data) + message_type = text_data_json.get('type', None) + + try: + if message_type == 'authenticate': + self.session_id = text_data_json.get('sessionid') + self.profile_id = text_data_json.get('profileid') + self.route = text_data_json.get('route') + self.project_id = text_data_json.get('projectid') + self.task_id = text_data_json.get('taskid') + self.ip_address = text_data_json.get('address') + profile = Profile.objects.filter(id=self.profile_id).first() + print(f"Authenticated with session_id: {self.session_id}, profile_id: {self.profile_id}, " + f"route: {self.route}, projectId: {self.project_id}, taskId: {self.task_id}") + if profile: + self.company_bot = CompanyBot.objects.get(company=profile.company, route='/') + else: + self.company_bot = CompanyBot.objects.get(route='/') + if self.task_id: + other_params = { + "task_id": self.task_id + } + else: + other_params = {} + # chat session create (session, profile) + cs, cs_created = ChatSession.objects.get_or_create( + session=self.session_id, + defaults={ + 'profile': profile, + 'current_step': 1, + 'company_bot': self.company_bot, + 'language': self.route, + 'session_status': ChatStatus.IN_PROGRESS, + 'session_type': ChatType.guidedReflection, + 'project_id': self.project_id, + 'other_params': other_params + } + ) + print(cs, cs_created) + + if not cs_created: + if cs.language != self.route: + cs.language = self.route + + other_params = cs.other_params or {} + other_params["ip_address"] = self.ip_address + + cs.other_params = other_params + + cs.save(update_fields=["language", "other_params"]) + else: + cs.other_params = {"ip_address": self.ip_address} + cs.save(update_fields=["other_params"]) + + project = Project.objects.filter(project_id=self.project_id).first() + if not project: + print(f"Project with ID {self.project_id} not found. Creating a new one.") + project = Project.objects.create( + project_id=self.project_id, + author=profile, + project_status=ProjectStatus.STARTED, + ) + print(f"Project created with id {project.id}") + else: + print(f"Found existing project: {project.id}") + else: + company_chat_status = self.determine_company_chat_status( + session_id=self.session_id, profile_id=self.profile_id, route='/' + ) + print("COMPANY CHAT STATUS: ", company_chat_status) + async_to_sync(self.channel_layer.send)( + self.channel_name, + { + "type": "chat_message", + "text": {"msg": text_data_json["text"], "source": "user"}, + }, + ) + + if self.route != 'en' and text_data_json and text_data_json.get('text'): + voice_provider = Voice.objects.filter( + company_bot=self.company_bot, type=VoiceType.TextToText, language=self.route + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=text_data_json['text'], target_language='en', + source_language=self.route + ) + if response.get('status') == 200: + translated_message = response.get('content') + else: + translated_message = text_data_json['text'] + else: + translated_message = None + + print("text_data_json: ", text_data_json) + if message_type != 'authenticate' and text_data_json and text_data_json.get('text'): + chat_session = ChatSession.objects.filter(session=self.session_id).order_by('-created_at').first() + current_stage = None + if chat_session and self.company_bot: + state_machine = CompanyStateMachine.objects.get( + company_bot=self.company_bot, step=chat_session.current_step + ) + if state_machine: + current_stage = state_machine.name + save_in_company_db( + session_id=self.session_id, profile_id=self.profile_id, initiated_by='User', + message=text_data_json['text'], chunks=None, status=company_chat_status, + translated_message=translated_message, audio_base64=text_data_json.get('asr_audio'), + stage=current_stage + ) + + print(f"channel_name: {self.channel_name}, session_id: {self.session_id}, profile_id: " + f"{self.profile_id}, route: {self.route}") + + if message_type != 'authenticate': + get_shikshalokam_bedrock_response.delay( + self.channel_name, self.session_id, self.profile_id, self.route + ) + except Exception as e: + print(e) + logger.error('Receive Error: %s', e, exc_info=True) + traceback.print_exc() + + def connect(self): + try: + print('Attempting to connect to websocket') + super().connect() + except Exception as e: + logger.error('Connect Error: %s', e, exc_info=True) + print(f"Connect Error: {e}") + traceback.print_exc() + except Exception as e: + logger.error('Error: %s', e, exc_info=True) + print(f"Error: {e}") diff --git a/chatbot/cron_tasks/__init__.py b/chatbot/cron_tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/cron_tasks/bihar_teacher_tool/story_creation.py b/chatbot/cron_tasks/bihar_teacher_tool/story_creation.py new file mode 100644 index 0000000..8b2214e --- /dev/null +++ b/chatbot/cron_tasks/bihar_teacher_tool/story_creation.py @@ -0,0 +1,240 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, CompanyBot, CompanyChat, Story, ChatStatus +from chatbot.models.enums import LLMProvider, StoryStatusChoices +from datetime import timedelta +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +import logging + + +logger = logging.getLogger('django') + +STUDY_TEACHER_SESSION_TYPE = 'study_teacher_interview' +STUDY_TEACHER_BOT_ROUTE = '/story-creation-study-teacher' +STORY_LLM_MAX_ATTEMPTS = 3 + + +def _story_response_has_title(response): + """True if the LLM returned a dict with a non-empty title.""" + if not isinstance(response, dict): + return False + title = response.get('title') + return title is not None and str(title).strip() != '' + + +def chat_sessions_without_story(session_type=STUDY_TEACHER_SESSION_TYPE): + """ + ChatSession rows with session_status COMPLETED that have no Story with the same + `session` string (Story links via Story.session, not a FK on ChatSession). + Pass session_type=None to include all session types. + """ + linked_story = Story.objects.filter(session=OuterRef('session')) + half_hour_ago = timezone.now() - timedelta(minutes=30) + qs = ChatSession.objects.filter( + ~Exists(linked_story), + session_type=session_type, + ).filter( + Q(created_at__lte=half_hour_ago) + | Q(created_at__gte=half_hour_ago, session_status=ChatStatus.COMPLETED) + ) + return qs + + +def chat_session_ids_without_story(session_type=STUDY_TEACHER_SESSION_TYPE): + """`session` values (string ids) for ChatSessions that have no matching Story.""" + return chat_sessions_without_story(session_type=session_type).values_list('session', flat=True) + + +def _build_chat_transcript(company_chats): + transcript_parts = [] + question_index = 0 + answer_index = 0 + for chat in company_chats: + if chat.sender_id == 1: + question_index += 1 + message = chat.message + transcript_parts.append( + f"\n{message}\n" + ) + else: + answer_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + return '\n'.join(transcript_parts) + + +def _build_messages(company_bot, company_chats): + transcript = _build_chat_transcript(company_chats=company_chats) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [ + { + 'role': 'user', + 'content': [{'text': transcript}] + }, + { + 'role': 'assistant', + 'content': [{'text': "```json"}] + } + ] + return [{ + 'role': 'user', + 'content': transcript + }] + + +def _get_system_prompt(company_bot): + prompt_parts = [company_bot.context, company_bot.end_context] + prompt_text = '\n\n'.join([part for part in prompt_parts if part and part.strip()]) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': prompt_text}] if prompt_text else None + return prompt_text + + +def _call_story_llm(company_bot, messages, system_prompt): + """Single LLM invocation for story JSON (no retries).""" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + print("messages: ", messages) + print("system_prompt: ", system_prompt) + return handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt, + messages=messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + is_json_response=False, + stop_sequences=["```"], + ) + + openai_messages = messages + if system_prompt: + openai_messages = [{'role': 'system', 'content': system_prompt}] + messages + return handle_openai_model( + company_bot=company_bot, + messages=openai_messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + model_name=company_bot.llm_model, + top_p=company_bot.filter_score, + is_json_response=False, + key_name=company_bot.llm_key or 'OPENAI_API_KEY', + is_actual_key=bool(company_bot.provider_keys), + ) + + +def _generate_story_for_session(company_bot, session): + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + if not company_chats.exists(): + logger.info('No chats found for session=%s', session) + return None + + messages = _build_messages(company_bot=company_bot, company_chats=company_chats) + system_prompt = _get_system_prompt(company_bot) + + if company_bot.provider not in ( + LLMProvider.BEDROCK_CONVERSE, + LLMProvider.OPENAI, + ): + logger.warning('Unsupported provider=%s for bot=%s', company_bot.provider, company_bot.id) + return None + + for attempt in range(1, STORY_LLM_MAX_ATTEMPTS + 1): + try: + response = _call_story_llm(company_bot, messages, system_prompt) + except Exception as e: + log_fn = logger.error if attempt == STORY_LLM_MAX_ATTEMPTS else logger.warning + log_fn( + 'Story LLM call failed session=%s attempt=%s/%s: %s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + e, + exc_info=(attempt == STORY_LLM_MAX_ATTEMPTS), + ) + continue + + if _story_response_has_title(response): + return response + + logger.warning( + 'Story LLM missing or empty title session=%s attempt=%s/%s response=%s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + response, + ) + + return None + + +def _persist_story_from_llm_response(session_id, response): + """ + Save LLM story JSON: title on Story.title, remaining keys in Story.other_params, + session id on Story.session, author from ChatSession.profile when present. + """ + title = str(response['title']).strip() + other_params = {k: v for k, v in response.items() if k != 'title'} + chat_session = ( + ChatSession.objects.filter(session=session_id) + .select_related('profile') + .first() + ) + author = chat_session.profile if chat_session else None + return Story.objects.create( + session=session_id, + title=title, + other_params=other_params, + author=author, + stage=StoryStatusChoices.COMPLETED + ) + + +def create_story(): + try: + company_bot = CompanyBot.objects.filter(route=STUDY_TEACHER_BOT_ROUTE).first() + if not company_bot: + logger.error('No CompanyBot found for route=%s', STUDY_TEACHER_BOT_ROUTE) + return + + # INNER JOIN chatbot_chatsession ON session WHERE session_type = delhi-shiksha-samvad + delhi_session_for_story = ChatSession.objects.filter( + session=OuterRef('session'), + session_type=STUDY_TEACHER_SESSION_TYPE, + ) + matching_stories = Story.objects.filter(Exists(delhi_session_for_story)) + + sessions_missing_story = chat_sessions_without_story() + + logger.info( + 'Creating story for Delhi Shiksha Samvad (%s stories, %s chat sessions without story)', + matching_stories.count(), + sessions_missing_story.count(), + ) + for session in sessions_missing_story.values_list('session', flat=True): + response = _generate_story_for_session(company_bot=company_bot, session=session) + + if response is None: + logger.error( + 'No valid story after %s LLM attempts for session=%s', + STORY_LLM_MAX_ATTEMPTS, + session, + ) + continue + + story = _persist_story_from_llm_response(session_id=session, response=response) + logger.info( + 'Created story id=%s session=%s route=%s title=%s other_params_keys=%s', + story.id, + session, + STUDY_TEACHER_BOT_ROUTE, + story.title, + list(story.other_params.keys()) if story.other_params else [], + ) + + except Exception as e: + logger.error('Error creating story for Delhi Shiksha Samvad: %s', e) \ No newline at end of file diff --git a/chatbot/cron_tasks/chaupal/chaupal_cront_tasks.py b/chatbot/cron_tasks/chaupal/chaupal_cront_tasks.py new file mode 100644 index 0000000..b62bc53 --- /dev/null +++ b/chatbot/cron_tasks/chaupal/chaupal_cront_tasks.py @@ -0,0 +1,82 @@ +import logging +from django.utils import timezone +from datetime import datetime, timedelta +import pytz +import ast +from django.db import connection +from chatbot.models import CompanyBot +from chatbot.scripts.guest_discussion.clean_story_script import get_story_count, clean_specific_stories +from chatbot.scripts.guest_discussion.post_processing.village_data_cleaning import run_for_specific_stories +# from chatbot.scripts.guest_discussion.translate_script import get_translate_story_count, translate_specific_story_ids +from chatbot.models import Story +import json + +logger = logging.getLogger('django') + + +def handle_story_cleanup_cron(): + try: + logger.info('🧹 Starting story cleanup cron at: {}'.format(timezone.now())) + ist = pytz.timezone("Asia/Kolkata") + yesterday = datetime.now().astimezone(ist) - timedelta(days=1) + start_time = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + end_time = yesterday.replace(hour=23, minute=59, second=59, microsecond=0) + logger.info(f"⏱ Using start_time: {start_time.isoformat()}") + logger.info(f"⏱ Using end_time: {end_time.isoformat()}") + + story_ids = get_story_count(start_time=start_time, end_time=end_time) + clean_specific_stories(story_ids=story_ids) + logger.info('✅ Story cleanup cron completed at: {}'.format(timezone.now())) + # logger.info('🚀 Starting translation immediately after cleanup...') + # story_ids = get_translate_story_count(start_time=start_time, end_time=end_time) + # translate_specific_story_ids(story_ids=story_ids) + # logger.info('✅ Story translation (chained) completed at: {}'.format(timezone.now())) + + except Exception as e: + logger.exception("❌ Error during story cleanup/translation cron: %s", str(e)) + + +def handle_village_ingestion_cron(): + try: + logger.info('🧹 Starting village ingestion cron at: %s', timezone.now()) + + bot = CompanyBot.objects.filter(route='/script_village_mapping').first() + master_villages = json.loads(bot.end_context) + + result = list(Story.objects.filter( + other_params__flow='guest-discussion' + ).exclude( + other_params__village__isnull= False + ).values('id')) + + + logger.info(f"📄 Raw story data: {result}") + + story_ids = [] + + try: + if isinstance(result, str): + story_dicts = ast.literal_eval(result) + else: + story_dicts = result + + for entry in story_dicts: + if isinstance(entry, dict) and 'id' in entry: + story_ids.append(entry['id']) + elif isinstance(entry, (str, int)): + story_ids.append(entry) + + except Exception as parse_err: + logger.exception("❌ Failed to parse SQL result: %s", str(parse_err)) + return + + if not story_ids: + logger.warning("⚠️ No story IDs found from SQL. Skipping village mapping.") + return + + summary = run_for_specific_stories(story_ids=story_ids, master_villages = master_villages) + logger.info(f'✅ Village summary: {summary}') + logger.info('🎉 Completed village ingestion at: %s', timezone.now()) + + except Exception as e: + logger.exception("❌ Error during village ingestion cron: %s", str(e)) diff --git a/chatbot/cron_tasks/community_FGD/story_creation.py b/chatbot/cron_tasks/community_FGD/story_creation.py new file mode 100644 index 0000000..84f3aa9 --- /dev/null +++ b/chatbot/cron_tasks/community_FGD/story_creation.py @@ -0,0 +1,241 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, CompanyBot, CompanyChat, Story, ChatStatus +from chatbot.models.enums import LLMProvider, StoryStatusChoices +from datetime import timedelta +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +import logging + + +logger = logging.getLogger('django') + +COMMUNITY_FGD_SESSION_TYPE = 'community-fgd' +COMMUNITY_FGD_BOT_ROUTE = '/story-creation-community-fgd' +STORY_LLM_MAX_ATTEMPTS = 3 + + +def _story_response_has_title(response): + """True if the LLM returned a dict with a non-empty title.""" + if not isinstance(response, dict): + return False + title = response.get('title') + return title is not None and str(title).strip() != '' + + +def chat_sessions_without_story(session_type=COMMUNITY_FGD_SESSION_TYPE): + """ + ChatSession rows with session_status COMPLETED that have no Story with the same + `session` string (Story links via Story.session, not a FK on ChatSession). + Pass session_type=None to include all session types. + """ + linked_story = Story.objects.filter(session=OuterRef('session')) + half_hour_ago = timezone.now() - timedelta(minutes=30) + qs = ChatSession.objects.filter( + ~Exists(linked_story), + session_type=session_type, + ).filter( + Q(created_at__lte=half_hour_ago) + | Q(created_at__gte=half_hour_ago, session_status=ChatStatus.COMPLETED) + ) + return qs + + +def chat_session_ids_without_story(session_type=COMMUNITY_FGD_SESSION_TYPE): + """`session` values (string ids) for ChatSessions that have no matching Story.""" + return chat_sessions_without_story(session_type=session_type).values_list('session', flat=True) + + +def _build_chat_transcript(company_chats): + transcript_parts = [] + question_index = 0 + answer_index = 0 + for chat in company_chats: + if chat.sender_id == 1: + question_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + else: + answer_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + return '\n'.join(transcript_parts) + + +def _build_messages(company_bot, company_chats): + transcript = _build_chat_transcript(company_chats=company_chats) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [ + { + 'role': 'user', + 'content': [{'text': transcript}] + }, + { + 'role': 'assistant', + 'content': [{'text': "```json"}] + } + ] + return [{ + 'role': 'user', + 'content': transcript + }] + + +def _get_system_prompt(company_bot): + prompt_parts = [company_bot.context, company_bot.end_context] + prompt_text = '\n\n'.join([part for part in prompt_parts if part and part.strip()]) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': prompt_text}] if prompt_text else None + return prompt_text + + +def _call_story_llm(company_bot, messages, system_prompt): + """Single LLM invocation for story JSON (no retries).""" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + print("messages: ", messages) + print("system_prompt: ", system_prompt) + return handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt, + messages=messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + is_json_response=False, + stop_sequences=["```"], + ) + + openai_messages = messages + if system_prompt: + openai_messages = [{'role': 'system', 'content': system_prompt}] + messages + return handle_openai_model( + company_bot=company_bot, + messages=openai_messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + model_name=company_bot.llm_model, + top_p=company_bot.filter_score, + is_json_response=False, + key_name=company_bot.llm_key or 'OPENAI_API_KEY', + is_actual_key=bool(company_bot.provider_keys), + ) + + +def _generate_story_for_session(company_bot, session): + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + if not company_chats.exists(): + logger.info('No chats found for session=%s', session) + return None + + messages = _build_messages(company_bot=company_bot, company_chats=company_chats) + system_prompt = _get_system_prompt(company_bot) + + if company_bot.provider not in ( + LLMProvider.BEDROCK_CONVERSE, + LLMProvider.OPENAI, + ): + logger.warning('Unsupported provider=%s for bot=%s', company_bot.provider, company_bot.id) + return None + + for attempt in range(1, STORY_LLM_MAX_ATTEMPTS + 1): + try: + response = _call_story_llm(company_bot, messages, system_prompt) + except Exception as e: + log_fn = logger.error if attempt == STORY_LLM_MAX_ATTEMPTS else logger.warning + log_fn( + 'Story LLM call failed session=%s attempt=%s/%s: %s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + e, + exc_info=(attempt == STORY_LLM_MAX_ATTEMPTS), + ) + continue + + if _story_response_has_title(response): + return response + + logger.warning( + 'Story LLM missing or empty title session=%s attempt=%s/%s response=%s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + response, + ) + + return None + + +def _persist_story_from_llm_response(session_id, response): + """ + Save LLM story JSON: title on Story.title, remaining keys in Story.other_params, + session id on Story.session, author from ChatSession.profile when present. + """ + title = str(response['title']).strip() + other_params = {k: v for k, v in response.items() if k != 'title'} + chat_session = ( + ChatSession.objects.filter(session=session_id) + .select_related('profile') + .first() + ) + author = chat_session.profile if chat_session else None + return Story.objects.create( + session=session_id, + title=title, + other_params=other_params, + author=author, + stage=StoryStatusChoices.COMPLETED + ) + + +def create_story(): + try: + company_bot = CompanyBot.objects.filter(route=COMMUNITY_FGD_BOT_ROUTE).first() + if not company_bot: + logger.error('No CompanyBot found for route=%s', COMMUNITY_FGD_BOT_ROUTE) + return + + community_fgd_session_for_story = ChatSession.objects.filter( + session=OuterRef('session'), + session_type=COMMUNITY_FGD_SESSION_TYPE, + ) + matching_stories = Story.objects.filter(Exists(community_fgd_session_for_story)) + + sessions_missing_story = chat_sessions_without_story() + + logger.info( + 'Creating story for Community FGD (%s stories, %s chat sessions without story)', + matching_stories.count(), + sessions_missing_story.count(), + ) + for session in sessions_missing_story.values_list('session', flat=True): + response = _generate_story_for_session(company_bot=company_bot, session=session) + + if response is None: + logger.error( + 'No valid story after %s LLM attempts for session=%s', + STORY_LLM_MAX_ATTEMPTS, + session, + ) + continue + + story = _persist_story_from_llm_response(session_id=session, response=response) + logger.info( + 'Created story id=%s session=%s route=%s title=%s other_params_keys=%s', + story.id, + session, + COMMUNITY_FGD_BOT_ROUTE, + story.title, + list(story.other_params.keys()) if story.other_params else [], + ) + + except Exception as e: + logger.error('Error creating story for Community FGD: %s', e) \ No newline at end of file diff --git a/chatbot/cron_tasks/delhi_shiksha_samvad/story_creation.py b/chatbot/cron_tasks/delhi_shiksha_samvad/story_creation.py new file mode 100644 index 0000000..9dfa2f6 --- /dev/null +++ b/chatbot/cron_tasks/delhi_shiksha_samvad/story_creation.py @@ -0,0 +1,242 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, CompanyBot, CompanyChat, Story, ChatStatus +from chatbot.models.enums import LLMProvider, StoryStatusChoices +from datetime import timedelta +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +import logging + + +logger = logging.getLogger('django') + +DELHI_SHIKSHA_SAMVAD_SESSION_TYPE = 'delhi-shiksha-samvad' +DELHI_SHIKSHA_SAMVAD_BOT_ROUTE = '/story-creation-delhi-shikshasamvad' +STORY_LLM_MAX_ATTEMPTS = 3 + + +def _story_response_has_title(response): + """True if the LLM returned a dict with a non-empty title.""" + if not isinstance(response, dict): + return False + title = response.get('title') + return title is not None and str(title).strip() != '' + + +def chat_sessions_without_story(session_type=DELHI_SHIKSHA_SAMVAD_SESSION_TYPE): + """ + ChatSession rows with session_status COMPLETED that have no Story with the same + `session` string (Story links via Story.session, not a FK on ChatSession). + Pass session_type=None to include all session types. + """ + linked_story = Story.objects.filter(session=OuterRef('session')) + half_hour_ago = timezone.now() - timedelta(minutes=30) + qs = ChatSession.objects.filter( + ~Exists(linked_story), + session_type=session_type, + ).filter( + Q(created_at__lte=half_hour_ago) + | Q(created_at__gte=half_hour_ago, session_status=ChatStatus.COMPLETED) + ) + return qs + + +def chat_session_ids_without_story(session_type=DELHI_SHIKSHA_SAMVAD_SESSION_TYPE): + """`session` values (string ids) for ChatSessions that have no matching Story.""" + return chat_sessions_without_story(session_type=session_type).values_list('session', flat=True) + + +def _build_chat_transcript(company_chats): + transcript_parts = [] + question_index = 0 + answer_index = 0 + for chat in company_chats: + if chat.sender_id == 1: + question_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + else: + answer_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + return '\n'.join(transcript_parts) + + +def _build_messages(company_bot, company_chats): + transcript = _build_chat_transcript(company_chats=company_chats) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [ + { + 'role': 'user', + 'content': [{'text': transcript}] + }, + { + 'role': 'assistant', + 'content': [{'text': "```json"}] + } + ] + return [{ + 'role': 'user', + 'content': transcript + }] + + +def _get_system_prompt(company_bot): + prompt_parts = [company_bot.context, company_bot.end_context] + prompt_text = '\n\n'.join([part for part in prompt_parts if part and part.strip()]) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': prompt_text}] if prompt_text else None + return prompt_text + + +def _call_story_llm(company_bot, messages, system_prompt): + """Single LLM invocation for story JSON (no retries).""" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + print("messages: ", messages) + print("system_prompt: ", system_prompt) + return handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt, + messages=messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + is_json_response=False, + stop_sequences=["```"], + ) + + openai_messages = messages + if system_prompt: + openai_messages = [{'role': 'system', 'content': system_prompt}] + messages + return handle_openai_model( + company_bot=company_bot, + messages=openai_messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + model_name=company_bot.llm_model, + top_p=company_bot.filter_score, + is_json_response=False, + key_name=company_bot.llm_key or 'OPENAI_API_KEY', + is_actual_key=bool(company_bot.provider_keys), + ) + + +def _generate_story_for_session(company_bot, session): + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + if not company_chats.exists(): + logger.info('No chats found for session=%s', session) + return None + + messages = _build_messages(company_bot=company_bot, company_chats=company_chats) + system_prompt = _get_system_prompt(company_bot) + + if company_bot.provider not in ( + LLMProvider.BEDROCK_CONVERSE, + LLMProvider.OPENAI, + ): + logger.warning('Unsupported provider=%s for bot=%s', company_bot.provider, company_bot.id) + return None + + for attempt in range(1, STORY_LLM_MAX_ATTEMPTS + 1): + try: + response = _call_story_llm(company_bot, messages, system_prompt) + except Exception as e: + log_fn = logger.error if attempt == STORY_LLM_MAX_ATTEMPTS else logger.warning + log_fn( + 'Story LLM call failed session=%s attempt=%s/%s: %s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + e, + exc_info=(attempt == STORY_LLM_MAX_ATTEMPTS), + ) + continue + + if _story_response_has_title(response): + return response + + logger.warning( + 'Story LLM missing or empty title session=%s attempt=%s/%s response=%s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + response, + ) + + return None + + +def _persist_story_from_llm_response(session_id, response): + """ + Save LLM story JSON: title on Story.title, remaining keys in Story.other_params, + session id on Story.session, author from ChatSession.profile when present. + """ + title = str(response['title']).strip() + other_params = {k: v for k, v in response.items() if k != 'title'} + chat_session = ( + ChatSession.objects.filter(session=session_id) + .select_related('profile') + .first() + ) + author = chat_session.profile if chat_session else None + return Story.objects.create( + session=session_id, + title=title, + other_params=other_params, + author=author, + stage=StoryStatusChoices.COMPLETED + ) + + +def create_story(): + try: + company_bot = CompanyBot.objects.filter(route=DELHI_SHIKSHA_SAMVAD_BOT_ROUTE).first() + if not company_bot: + logger.error('No CompanyBot found for route=%s', DELHI_SHIKSHA_SAMVAD_BOT_ROUTE) + return + + # INNER JOIN chatbot_chatsession ON session WHERE session_type = delhi-shiksha-samvad + delhi_session_for_story = ChatSession.objects.filter( + session=OuterRef('session'), + session_type=DELHI_SHIKSHA_SAMVAD_SESSION_TYPE, + ) + matching_stories = Story.objects.filter(Exists(delhi_session_for_story)) + + sessions_missing_story = chat_sessions_without_story() + + logger.info( + 'Creating story for Delhi Shiksha Samvad (%s stories, %s chat sessions without story)', + matching_stories.count(), + sessions_missing_story.count(), + ) + for session in sessions_missing_story.values_list('session', flat=True): + response = _generate_story_for_session(company_bot=company_bot, session=session) + + if response is None: + logger.error( + 'No valid story after %s LLM attempts for session=%s', + STORY_LLM_MAX_ATTEMPTS, + session, + ) + continue + + story = _persist_story_from_llm_response(session_id=session, response=response) + logger.info( + 'Created story id=%s session=%s route=%s title=%s other_params_keys=%s', + story.id, + session, + DELHI_SHIKSHA_SAMVAD_BOT_ROUTE, + story.title, + list(story.other_params.keys()) if story.other_params else [], + ) + + except Exception as e: + logger.error('Error creating story for Delhi Shiksha Samvad: %s', e) \ No newline at end of file diff --git a/chatbot/cron_tasks/shiksha_samvad/story_creation.py b/chatbot/cron_tasks/shiksha_samvad/story_creation.py new file mode 100644 index 0000000..efe6d15 --- /dev/null +++ b/chatbot/cron_tasks/shiksha_samvad/story_creation.py @@ -0,0 +1,241 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, CompanyBot, CompanyChat, Story, ChatStatus +from chatbot.models.enums import LLMProvider, StoryStatusChoices +from datetime import timedelta +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +import logging + + +logger = logging.getLogger('django') + +SHIKSHA_SAMVAD_SESSION_TYPE = 'shiksha-samvad' +SHIKSHA_SAMVAD_BOT_ROUTE = '/story-creation-shikshasamvad' +STORY_LLM_MAX_ATTEMPTS = 3 + + +def _story_response_has_title(response): + """True if the LLM returned a dict with a non-empty title.""" + if not isinstance(response, dict): + return False + title = response.get('title') + return title is not None and str(title).strip() != '' + + +def chat_sessions_without_story(session_type=SHIKSHA_SAMVAD_SESSION_TYPE): + """ + ChatSession rows with session_status COMPLETED that have no Story with the same + `session` string (Story links via Story.session, not a FK on ChatSession). + Pass session_type=None to include all session types. + """ + linked_story = Story.objects.filter(session=OuterRef('session')) + half_hour_ago = timezone.now() - timedelta(minutes=30) + qs = ChatSession.objects.filter( + ~Exists(linked_story), + session_type=session_type, + ).filter( + Q(created_at__lte=half_hour_ago) + | Q(created_at__gte=half_hour_ago, session_status=ChatStatus.COMPLETED) + ) + return qs + + +def chat_session_ids_without_story(session_type=SHIKSHA_SAMVAD_SESSION_TYPE): + """`session` values (string ids) for ChatSessions that have no matching Story.""" + return chat_sessions_without_story(session_type=session_type).values_list('session', flat=True) + + +def _build_chat_transcript(company_chats): + transcript_parts = [] + question_index = 0 + answer_index = 0 + for chat in company_chats: + if chat.sender_id == 1: + question_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + else: + answer_index += 1 + message = chat.message + if chat.translated_message is not None and chat.translated_message != '': + message = chat.translated_message + transcript_parts.append( + f"\n{message}\n" + ) + return '\n'.join(transcript_parts) + + +def _build_messages(company_bot, company_chats): + transcript = _build_chat_transcript(company_chats=company_chats) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [ + { + 'role': 'user', + 'content': [{'text': transcript}] + }, + { + 'role': 'assistant', + 'content': [{'text': "```json"}] + } + ] + return [{ + 'role': 'user', + 'content': transcript + }] + + +def _get_system_prompt(company_bot): + prompt_parts = [company_bot.context, company_bot.end_context] + prompt_text = '\n\n'.join([part for part in prompt_parts if part and part.strip()]) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': prompt_text}] if prompt_text else None + return prompt_text + + +def _call_story_llm(company_bot, messages, system_prompt): + """Single LLM invocation for story JSON (no retries).""" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + print("messages: ", messages) + print("system_prompt: ", system_prompt) + return handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt, + messages=messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + is_json_response=False, + stop_sequences=["```"], + ) + + openai_messages = messages + if system_prompt: + openai_messages = [{'role': 'system', 'content': system_prompt}] + messages + return handle_openai_model( + company_bot=company_bot, + messages=openai_messages, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + model_name=company_bot.llm_model, + top_p=company_bot.filter_score, + is_json_response=False, + key_name=company_bot.llm_key or 'OPENAI_API_KEY', + is_actual_key=bool(company_bot.provider_keys), + ) + + +def _generate_story_for_session(company_bot, session): + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + if not company_chats.exists(): + logger.info('No chats found for session=%s', session) + return None + + messages = _build_messages(company_bot=company_bot, company_chats=company_chats) + system_prompt = _get_system_prompt(company_bot) + + if company_bot.provider not in ( + LLMProvider.BEDROCK_CONVERSE, + LLMProvider.OPENAI, + ): + logger.warning('Unsupported provider=%s for bot=%s', company_bot.provider, company_bot.id) + return None + + for attempt in range(1, STORY_LLM_MAX_ATTEMPTS + 1): + try: + response = _call_story_llm(company_bot, messages, system_prompt) + except Exception as e: + log_fn = logger.error if attempt == STORY_LLM_MAX_ATTEMPTS else logger.warning + log_fn( + 'Story LLM call failed session=%s attempt=%s/%s: %s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + e, + exc_info=(attempt == STORY_LLM_MAX_ATTEMPTS), + ) + continue + + if _story_response_has_title(response): + return response + + logger.warning( + 'Story LLM missing or empty title session=%s attempt=%s/%s response=%s', + session, + attempt, + STORY_LLM_MAX_ATTEMPTS, + response, + ) + + return None + + +def _persist_story_from_llm_response(session_id, response): + """ + Save LLM story JSON: title on Story.title, remaining keys in Story.other_params, + session id on Story.session, author from ChatSession.profile when present. + """ + title = str(response['title']).strip() + other_params = {k: v for k, v in response.items() if k != 'title'} + chat_session = ( + ChatSession.objects.filter(session=session_id) + .select_related('profile') + .first() + ) + author = chat_session.profile if chat_session else None + return Story.objects.create( + session=session_id, + title=title, + other_params=other_params, + author=author, + stage=StoryStatusChoices.COMPLETED + ) + + +def create_story(): + try: + company_bot = CompanyBot.objects.filter(route=SHIKSHA_SAMVAD_BOT_ROUTE).first() + if not company_bot: + logger.error('No CompanyBot found for route=%s', SHIKSHA_SAMVAD_BOT_ROUTE) + return + + shiksha_samvad_session_for_story = ChatSession.objects.filter( + session=OuterRef('session'), + session_type=SHIKSHA_SAMVAD_SESSION_TYPE, + ) + matching_stories = Story.objects.filter(Exists(shiksha_samvad_session_for_story)) + + sessions_missing_story = chat_sessions_without_story() + + logger.info( + 'Creating story for Shiksha Samvad (%s stories, %s chat sessions without story)', + matching_stories.count(), + sessions_missing_story.count(), + ) + for session in sessions_missing_story.values_list('session', flat=True): + response = _generate_story_for_session(company_bot=company_bot, session=session) + + if response is None: + logger.error( + 'No valid story after %s LLM attempts for session=%s', + STORY_LLM_MAX_ATTEMPTS, + session, + ) + continue + + story = _persist_story_from_llm_response(session_id=session, response=response) + logger.info( + 'Created story id=%s session=%s route=%s title=%s other_params_keys=%s', + story.id, + session, + SHIKSHA_SAMVAD_BOT_ROUTE, + story.title, + list(story.other_params.keys()) if story.other_params else [], + ) + + except Exception as e: + logger.error('Error creating story for Shiksha Samvad: %s', e) \ No newline at end of file diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/fuzzy_match.py b/chatbot/cron_tasks/telangana_ptm_pilot/fuzzy_match.py new file mode 100644 index 0000000..7913d0b --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/fuzzy_match.py @@ -0,0 +1,54 @@ +from rapidfuzz import process, fuzz +from chatbot.cron_tasks.telangana_ptm_pilot.normalize import normalize_text + +HIGH_CONFIDENCE = 85 +LOW_CONFIDENCE = 60 + + +def _find_duplicates(schools: list[dict]) -> set[str]: + seen: set[str] = set() + dupes: set[str] = set() + for s in schools: + key = normalize_text(s["school_name"]) + if key in seen: + dupes.add(key) + seen.add(key) + return dupes + + +class FuzzyMatcher: + def __init__(self, schools: list[dict]): + self._schools = schools + self._duplicates = _find_duplicates(schools) + self._choices = [normalize_text(s["school_name"]) for s in schools] + + def _best_match(self, query: str) -> tuple[dict | None, float]: + if not query: + return None, 0.0 + result = process.extractOne( + query, self._choices, scorer=fuzz.WRatio, score_cutoff=LOW_CONFIDENCE + ) + if result is None: + return None, 0.0 + matched_name, score, idx = result + school = self._schools[idx] + # Defer duplicates to LLM regardless of score + if normalize_text(school["school_name"]) in self._duplicates: + return school, score * 0.5 # artificially lower to trigger Tier 3 + return school, score + + def match(self, message: str, translated: str = "") -> tuple[dict | None, float, str]: + """Returns (school_row | None, score, method_label).""" + best_school, best_score = None, 0.0 + + for text in (message, translated): + query = normalize_text(text) + school, score = self._best_match(query) + if score > best_score: + best_school, best_score = school, score + + if best_school is None: + return None, 0.0, "FUZZY_LOW" + + method = "FUZZY_HIGH" if best_score >= HIGH_CONFIDENCE else "FUZZY_LOW" + return best_school, best_score, method diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/kpi_llm.py b/chatbot/cron_tasks/telangana_ptm_pilot/kpi_llm.py new file mode 100644 index 0000000..be63926 --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/kpi_llm.py @@ -0,0 +1,52 @@ +import json +import logging +import traceback + +from chatbot.llm_models.llm_script import handle_bedrock_model + +logger = logging.getLogger('django') + +BOT_ROUTE = '/telangana-ptm-metrics' +LLM_BATCH_SIZE = 25 + + +def _build_messages(batch: list) -> list: + payload = [{'id': s, 'field': f, 'text': t} for s, f, t in batch] + return [ + {'role': 'user', 'content': [{'text': json.dumps(payload)}]}, + ] + + +def _parse_response(response) -> dict: + try: + results = response.get('results', []) + return {str(item['id']): {item['field']: {k: v for k, v in item.items() if k not in ('id', 'field')}} + for item in results if 'id' in item and 'field' in item} + except Exception: + logger.warning('Failed to parse LLM response: %s', response) + return {} + + +def llm_classify_batches(items: list, company_bot): + """ + items: list of (session_id, field, text) + Yields (batch_session_ids: set[str], result: {session_id: {field: label_dict}}) per batch. + """ + for i in range(0, len(items), LLM_BATCH_SIZE): + batch = items[i:i + LLM_BATCH_SIZE] + batch_session_ids = {str(sid) for sid, _, _ in batch} + messages = _build_messages(batch) + try: + response = handle_bedrock_model( + company_bot=company_bot, + system_prompt=[{'text': company_bot.context}], + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + ) + parsed = _parse_response(response) + except Exception: + logger.error('LLM batch %d-%d failed:\n%s', i, i + LLM_BATCH_SIZE, traceback.format_exc()) + parsed = {} + yield batch_session_ids, parsed diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/kpi_rules.py b/chatbot/cron_tasks/telangana_ptm_pilot/kpi_rules.py new file mode 100644 index 0000000..fa7fb4d --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/kpi_rules.py @@ -0,0 +1,136 @@ +import json +import re + + +def load_keywords(dynamic_context_str) -> dict: + if isinstance(dynamic_context_str, dict): + return dynamic_context_str + # Strip invalid control characters that break json.loads (e.g. bare \t inside string values) + cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', dynamic_context_str) + return json.loads(cleaned) + + +def _norm(text: str) -> str: + text = text.lower() + # Keep Telugu Unicode block (0900-097F Devanagari, 0C00-0C7F Telugu) + text = re.sub(r'[^\w\s\u0C00-\u0C7F\u0900-\u097F]', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + return text + + +def _hit(text: str, keywords: list) -> bool: + for kw in keywords: + if _norm(kw) in text: + return True + return False + + +def classify_sentiment(text: str, kw: dict) -> tuple: + t = _norm(text) + sk = kw.get('sentiment', {}) + pos = _hit(t, sk.get('positive', [])) + neg = _hit(t, sk.get('negative', [])) + neu = _hit(t, sk.get('neutral', [])) + + if pos and neg: + return {'sentiment': 'mixed'}, 0.85 + if pos: + return {'sentiment': 'positive'}, 0.9 + if neg: + return {'sentiment': 'negative'}, 0.9 + if neu: + return {'sentiment': 'neutral'}, 0.85 + return None, 0.0 + + +def classify_ptm_scheduling(text: str, kw: dict) -> tuple: + t = _norm(text) + sk = kw.get('ptm_scheduling', {}) + + result = {} + + for dim, keywords_map in [('frequency', sk.get('frequency', {})), + ('day', sk.get('day', {})), + ('time', sk.get('time', {}))]: + matched = 'unspecified' + for label, keywords in keywords_map.items(): + if _hit(t, keywords): + matched = label + break + result[dim] = matched + + if all(v == 'unspecified' for v in result.values()): + return None, 0.0 + return result, 0.85 + + +def classify_resource_availability(text: str, kw: dict) -> tuple: + t = _norm(text) + sk = kw.get('resource_availability', {}) + + pos = _hit(t, sk.get('received_positive', [])) + neg = _hit(t, sk.get('received_negative', [])) + + if pos and neg: + return None, 0.0 + + if pos: + materials = [ + label for label, keywords in sk.get('materials', {}).items() + if _hit(t, keywords) + ] + return {'received': 'yes', 'materials': materials}, 0.9 + + if neg: + return {'received': 'no', 'materials': []}, 0.9 + + return None, 0.0 + + +def classify_programme_awareness(text: str, kw: dict) -> tuple: + t = _norm(text) + sk = kw.get('programme_awareness', {}) + + pos = _hit(t, sk.get('aware_positive', [])) + neg = _hit(t, sk.get('aware_negative', [])) + + if pos and neg: + return None, 0.0 + + if pos: + programmes = [ + label for label, keywords in sk.get('programmes', {}).items() + if _hit(t, keywords) + ] + return {'aware': 'yes', 'programmes': programmes}, 0.9 + + if neg: + return {'aware': 'no', 'programmes': []}, 0.9 + + return None, 0.0 + + +def classify_school_reputation(text: str, kw: dict) -> tuple: + t = _norm(text) + sk = kw.get('school_reputation', {}) + + pos = _hit(t, sk.get('recommend_positive', [])) + neg = _hit(t, sk.get('recommend_negative', [])) + + if pos and neg: + return None, 0.0 + if pos: + return {'recommend': 'yes'}, 0.9 + if neg: + return {'recommend': 'no'}, 0.9 + return None, 0.0 + + +STAGE_CLASSIFIERS = { + 'PTM_SCHEDULING_PREFERENCE': ('ptm_scheduling', classify_ptm_scheduling), + 'RESOURCE_AVAILABILITY': ('resource_availability', classify_resource_availability), + 'PROGRAM_AWARENESS': ('programme_awareness', classify_programme_awareness), + 'SCHOOL_REPUTATION': ('school_reputation', classify_school_reputation), +} + +CONFIDENCE_THRESHOLD = 0.8 diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/llm_classify.py b/chatbot/cron_tasks/telangana_ptm_pilot/llm_classify.py new file mode 100644 index 0000000..e217f7f --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/llm_classify.py @@ -0,0 +1,90 @@ +import traceback + +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models.company_models import CompanyBot + +BOT_ROUTE = '/classify-school-telangana-ptm' + + +def _build_candidate_block(candidates: list[dict]) -> str: + lines = [ + f"{s['district']} | {s['mandal']} | {s['school_name']} | {s['udise_code']}" + for s in candidates + ] + return "\n".join(lines) + + +def _build_user_prompt(message: str, candidates: list[dict]) -> str: + candidate_block = _build_candidate_block(candidates) + return ( + f"Parent's response: {message or ''}\n\n" + f"Candidate schools (DISTRICT | MANDAL | SCHOOL_NAME | UDISE_CODE):\n" + f"{candidate_block}" + ) + + +def _parse_bedrock_result(result) -> dict | None: + if not isinstance(result, dict): + return None + if result.get("result") == "UNMATCHED": + return None + if all(k in result for k in ("district", "mandal", "school_name", "udise_code")): + return { + "district": str(result["district"]).strip().upper(), + "mandal": str(result["mandal"]).strip().upper(), + "school_name": str(result["school_name"]).strip(), + "udise_code": str(result["udise_code"]).strip(), + } + return None + + +def _call_llm( + idx: int, + message: str, + candidates: list[dict], + company_bot: CompanyBot, +) -> tuple[int, dict | None]: + system_prompt = [{'text': company_bot.context}] + messages = [{'role': 'user', 'content': [{'text': _build_user_prompt(message, candidates)}]}] + try: + result = handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + ) + return idx, _parse_bedrock_result(result) + except Exception as e: + traceback.print_exc() + print(f" [row {idx}] Bedrock error: {e}") + return idx, None + + +def llm_classify( + items: list[tuple[str, list[dict]]], # (message, candidates) +) -> list[dict | None]: + """ + Classify via Bedrock Converse. Config (model, temperature, system prompt) from CompanyBot. + Each request only receives the top-K TF-IDF candidates, not the full school list. + """ + if not items: + return [] + + company_bot = CompanyBot.objects.get(route=BOT_ROUTE) + + results: dict[int, dict | None] = {} + total = len(items) + + for idx, (msg, candidates) in enumerate(items): + if not candidates: + results[idx] = None + continue + _, result = _call_llm(idx, msg, candidates, company_bot) + results[idx] = result + done = idx + 1 + if done % 50 == 0 or done == total: + print(f" LLM progress: {done}/{total}") + + return [results.get(i) for i in range(len(items))] diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/metrics_extraction.py b/chatbot/cron_tasks/telangana_ptm_pilot/metrics_extraction.py new file mode 100644 index 0000000..6ea3aed --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/metrics_extraction.py @@ -0,0 +1,167 @@ +import logging +import traceback +from collections import defaultdict +from datetime import timedelta + +from django.db.models import Exists, OuterRef +from django.utils import timezone + +from chatbot.models import CompanyChat, ChatSession +from chatbot.models.company_models import CompanyBot +from chatbot.models.story_models import Story +from chatbot.cron_tasks.telangana_ptm_pilot.kpi_rules import ( + load_keywords, + classify_sentiment, + STAGE_CLASSIFIERS, + CONFIDENCE_THRESHOLD, +) +from chatbot.cron_tasks.telangana_ptm_pilot.kpi_llm import llm_classify_batches + +logger = logging.getLogger('django') + + +def load_sessions_for_metrics() -> list: + one_hour_ago = timezone.now() - timedelta(hours=1) + recent_story = Story.objects.filter( + session=OuterRef('session'), + created_at__gte=one_hour_ago, + ) + already_processed = Story.objects.filter( + session=OuterRef('session'), + other_params__kpi_processed=True, + ) + return list( + ChatSession.objects + .filter(session_type='telangana-ptm-pilot') + .exclude(Exists(recent_story)) + .exclude(Exists(already_processed)) + .values_list('session', flat=True) + ) + + +def _build_session_chat_map(sessions: list) -> dict: + chats = ( + CompanyChat.objects + .filter(session__in=sessions) + .order_by('created_at') + .values('session', 'sender_id', 'stage', 'message', 'translated_message') + ) + result = defaultdict(list) + for chat in chats: + result[chat['session']].append(chat) + return dict(result) + + +def _effective_text(chat: dict) -> str: + return chat['translated_message'] or chat['message'] or '' + + +def _classify_stage_kpis(session_id: str, chats: list, keywords: dict) -> tuple: + rule_metrics = {} + llm_queue = [] + stage_texts = {} + for chat in chats: + if chat['sender_id'] != 1 and chat['stage'] in STAGE_CLASSIFIERS: + stage_texts[chat['stage']] = _effective_text(chat) + for stage, text in stage_texts.items(): + if not text: + continue + field_name, classifier_fn = STAGE_CLASSIFIERS[stage] + label, conf = classifier_fn(text, keywords) + if label is not None and conf >= CONFIDENCE_THRESHOLD: + rule_metrics[field_name] = label + else: + llm_queue.append((session_id, field_name, text)) + return rule_metrics, llm_queue + + +def _extract_kpi_for_session(session_id: str, chats: list, keywords: dict) -> tuple: + """ + Returns (rule_metrics: dict, llm_queue: list of (session_id, field, text)) + """ + rule_metrics = {} + llm_queue = [] + + user_texts = [_effective_text(c) for c in chats if c['sender_id'] != 1] + transcript = ' '.join(user_texts).strip() + if transcript: + label, conf = classify_sentiment(transcript, keywords) + if label is not None and conf >= CONFIDENCE_THRESHOLD: + rule_metrics['sentiment'] = label + else: + llm_queue.append((session_id, 'sentiment', transcript)) + + stage_metrics, stage_llm = _classify_stage_kpis(session_id, chats, keywords) + rule_metrics.update(stage_metrics) + llm_queue.extend(stage_llm) + + return rule_metrics, llm_queue + + +def _save_story_kpi(session_id: str, metrics: dict, error: str = None) -> None: + story = Story.objects.filter(session=session_id).first() + if not story: + logger.info('No Story for session=%s, skipping', session_id) + return + params = {**(story.other_params or {}), **metrics, 'kpi_processed': True} + if error: + params['kpi_error'] = error + story.other_params = params + story.save(update_fields=['other_params', 'updated_at']) + + +def extract_metrics(): + try: + company_bot = CompanyBot.objects.get(route='/telangana-ptm-metrics') + except CompanyBot.DoesNotExist: + logger.error('CompanyBot with route=/telangana-ptm-metrics not found. Create it in admin first.') + return + + keywords = load_keywords(company_bot.dynamic_context) + + sessions = load_sessions_for_metrics() + if not sessions: + logger.info('No sessions to process for KPI metrics extraction.') + return + + logger.info('KPI extraction starting — %d sessions to process', len(sessions)) + chat_map = _build_session_chat_map(sessions) + + all_rule_metrics = {} + all_llm_queue = [] + failed_sessions = {} + + for session_id in sessions: + try: + chats = chat_map.get(session_id, []) + rule_metrics, llm_queue = _extract_kpi_for_session(session_id, chats, keywords) + all_rule_metrics[str(session_id)] = rule_metrics + all_llm_queue.extend(llm_queue) + except Exception: + err = traceback.format_exc() + logger.error('Rule extraction failed for session=%s:\n%s', session_id, err) + failed_sessions[str(session_id)] = err + + sessions_needing_llm = {str(sid) for sid, _, _ in all_llm_queue} + + saved = 0 + for session_id in sessions: + sid = str(session_id) + if sid not in sessions_needing_llm: + _save_story_kpi(sid, dict(all_rule_metrics.get(sid, {})), failed_sessions.get(sid)) + saved += 1 + + if all_llm_queue: + logger.info('Sending %d items to LLM for KPI classification', len(all_llm_queue)) + for batch_sids, batch_result in llm_classify_batches(all_llm_queue, company_bot): + for sid in batch_sids: + merged = dict(all_rule_metrics.get(sid, {})) + for field, value in batch_result.get(sid, {}).items(): + if field not in merged: + merged[field] = value + if sid not in batch_result and sid not in failed_sessions: + failed_sessions[sid] = 'LLM classification returned no results' + _save_story_kpi(sid, merged, failed_sessions.get(sid)) + saved += 1 + + logger.info('KPI extraction complete — %d/%d sessions saved', saved, len(sessions)) diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/normalize.py b/chatbot/cron_tasks/telangana_ptm_pilot/normalize.py new file mode 100644 index 0000000..9477c83 --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/normalize.py @@ -0,0 +1,58 @@ +import io +import re +import pandas as pd + + +def normalize_text(text: str) -> str: + """Uppercase, strip punctuation (except hyphens), collapse whitespace.""" + if not text or text.strip().lower() in ("nan", "none", ""): + return "" + text = text.upper() + text = re.sub(r"[^\w\s\-]", " ", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def _parse_schools_df(df) -> list[dict]: + df.columns = ["district", "mandal", "school_name", "udise_code"] + df["district"] = df["district"].str.upper().str.strip() + df["mandal"] = df["mandal"].str.upper().str.strip() + df["school_name"] = df["school_name"].str.strip() + df["udise_code"] = df["udise_code"].astype(str).str.strip() + return df.to_dict("records") + + +def load_schools(csv_path: str) -> list[dict]: + return _parse_schools_df(pd.read_csv(csv_path)) + + +def load_schools_from_string(csv_content: str) -> list[dict]: + return _parse_schools_df(pd.read_csv(io.StringIO(csv_content))) + + +UNMATCHED_ROW = { + "district": "", + "mandal": "", + "school_name": "UNMATCHED", + "udise_code": "", +} + + +class Normalizer: + def __init__(self, schools: list[dict]): + self._lookup: dict[str, dict] = {} + self._duplicates: set[str] = set() + for school in schools: + key = normalize_text(school["school_name"]) + if key in self._lookup: + self._duplicates.add(key) + self._lookup[key] = school + + def match(self, message: str, translated: str = "") -> dict | None: + for text in (message, translated): + key = normalize_text(text) + if not key: + continue + if key in self._lookup and key not in self._duplicates: + return self._lookup[key] + return None diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/school_classification.py b/chatbot/cron_tasks/telangana_ptm_pilot/school_classification.py new file mode 100644 index 0000000..29c96a1 --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/school_classification.py @@ -0,0 +1,145 @@ +import csv +import os + +from django.db.models import Exists, OuterRef +from django.db.models.functions import Coalesce + +from chatbot.models import CompanyChat, ChatSession +from chatbot.models.company_models import CompanyBot +from chatbot.models.story_models import Story +from chatbot.cron_tasks.telangana_ptm_pilot.normalize import load_schools_from_string, Normalizer, UNMATCHED_ROW +from chatbot.cron_tasks.telangana_ptm_pilot.fuzzy_match import FuzzyMatcher, HIGH_CONFIDENCE, LOW_CONFIDENCE +from chatbot.cron_tasks.telangana_ptm_pilot.token_match import TokenMatcher, HIGH_TOKEN_SCORE +from chatbot.cron_tasks.telangana_ptm_pilot.llm_classify import llm_classify, BOT_ROUTE + + +def load_chats_from_db() -> list[dict]: + linked_story = Story.objects.filter(session=OuterRef('session')) + return list( + CompanyChat.objects + .filter( + stage='SCHOOL_NAME', + receiver_id=1, + session__in=ChatSession.objects.filter(session_type='telangana-ptm-pilot').values('session'), + ) + .exclude(Exists(linked_story)) + .annotate(effective_message=Coalesce('translated_message', 'message')) + .values('id', 'effective_message', 'session', 'created_at', 'updated_at', 'sender_id') + .order_by('-created_at') + ) + + +def load_chats_from_csv(path: str) -> list[dict]: + """Load mock chat data from CSV. Required columns: id, effective_message.""" + with open(path, newline='', encoding='utf-8') as f: + reader = csv.DictReader(f) + return [ + { + 'id': int(row['id']), + 'effective_message': row['effective_message'], + 'session': row.get('session'), + 'created_at': row.get('created_at'), + 'updated_at': row.get('updated_at'), + 'sender_id': int(row['sender_id']) if row.get('sender_id') else None, + } + for row in reader + ] + + +def main(csv_path: str | None = None): + company_bot = CompanyBot.objects.get(route=BOT_ROUTE) + + print("Loading data...") + chats = load_chats_from_csv(csv_path) if csv_path else load_chats_from_db() + schools = load_schools_from_string(company_bot.dynamic_context) + print(f" {len(chats)} user responses, {len(schools)} canonical schools") + + normalizer = Normalizer(schools) + fuzzer = FuzzyMatcher(schools) + tokener = TokenMatcher(schools) + + results: dict[int, tuple[dict, float, str]] = {} + + # Items that survive each tier: (row_index, message, fuzzy_fallback, fuzzy_score) + tier3_queue: list[tuple[int, str, dict | None, float]] = [] + + # ── Tier 1: exact match ── Tier 2: fuzzy ≥ 85 ────────────────────────── + print("\nTier 1 (exact) + Tier 2 (fuzzy ≥85)...") + for chat in chats: + i = chat['id'] + msg = str(chat['effective_message'] or "") + + match = normalizer.match(msg) + if match: + results[i] = (match, 1.0, "EXACT") + continue + + match, score, method = fuzzer.match(msg) + if match and score >= HIGH_CONFIDENCE: + results[i] = (match, score / 100, method) + else: + tier3_queue.append((i, msg, match, score)) + + print(f" EXACT: {sum(1 for _,_,m in results.values() if m=='EXACT')}") + print(f" FUZZY_HIGH: {sum(1 for _,_,m in results.values() if m=='FUZZY_HIGH')}") + print(f" → {len(tier3_queue)} rows queued for Tier 3") + + # ── Tier 3: TF-IDF token match ─────────────────────────────────────────── + llm_queue: list[tuple[int, str, dict | None, float, list[dict]]] = [] + + if tier3_queue: + print("\nTier 3 (TF-IDF token match)...") + for i, msg, fuzzy_match, fuzzy_score in tier3_queue: + token_match, token_score, candidates, token_method = tokener.match(msg) + if token_match and token_score >= HIGH_TOKEN_SCORE: + results[i] = (token_match, token_score, "TOKEN_HIGH") + else: + # Keep best fuzzy fallback; pass TF-IDF candidates to LLM + best_fallback = token_match if token_match else fuzzy_match + best_fallback_score = token_score if token_match else fuzzy_score / 100 + llm_queue.append((i, msg, best_fallback, best_fallback_score, candidates)) + + print(f" TOKEN_HIGH: {sum(1 for _,_,m in results.values() if m=='TOKEN_HIGH')}") + print(f" → {len(llm_queue)} rows queued for Tier 4 (LLM)") + + # ── Tier 4: Bedrock Llama 3.3 70B ──────────────────────────────────────── + if llm_queue: + use_llm = bool(os.environ.get("AWS_PROFILE") or os.environ.get("AWS_ACCESS_KEY_ID")) + if not use_llm: + print("\nWARNING: No AWS credentials found (set AWS_PROFILE or AWS_ACCESS_KEY_ID).") + print(" Falling back to best available match for queued rows.") + else: + print("\nTier 4 (Bedrock Converse — Llama 3.3 70B)...") + + llm_inputs = [(msg, candidates) for _, msg, _, _, candidates in llm_queue] + llm_results = llm_classify(llm_inputs) if use_llm else [None] * len(llm_queue) + + for (i, msg, fallback, fallback_score, _), llm_result in zip(llm_queue, llm_results): + if llm_result: + results[i] = (llm_result, 0.9, "LLM") + elif fallback and fallback_score >= LOW_CONFIDENCE / 100: + results[i] = (fallback, fallback_score, "FUZZY_LOW") + else: + results[i] = (UNMATCHED_ROW, 0.0, "UNMATCHED") + + # ── Save results to Story model ─────────────────────────────────────────── + print(f"\nSaving {len(results)} results to Story model...") + chat_meta = {chat['id']: chat for chat in chats} + saved = 0 + for chat_id, (school, score, method) in results.items(): + chat = chat_meta.get(chat_id, {}) + Story.objects.update_or_create( + session=chat.get('session') or str(chat_id), + defaults={ + 'title': 'Telangana School Classification', + 'author_id': chat.get('sender_id'), + 'other_params': { + 'school': school, + 'score': score, + 'method': method, + 'message': chat.get('effective_message', ''), + }, + }, + ) + saved += 1 + print(f" Saved {saved} Story records.") \ No newline at end of file diff --git a/chatbot/cron_tasks/telangana_ptm_pilot/token_match.py b/chatbot/cron_tasks/telangana_ptm_pilot/token_match.py new file mode 100644 index 0000000..6bdb1c2 --- /dev/null +++ b/chatbot/cron_tasks/telangana_ptm_pilot/token_match.py @@ -0,0 +1,72 @@ +import numpy as np +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity + +from chatbot.cron_tasks.telangana_ptm_pilot.normalize import normalize_text + +HIGH_TOKEN_SCORE = 0.70 +TOP_K = 10 # candidates passed to LLM tier + + +def _find_duplicates(schools: list[dict]) -> set[str]: + seen: set[str] = set() + dupes: set[str] = set() + for s in schools: + key = normalize_text(s["school_name"]) + if key in seen: + dupes.add(key) + seen.add(key) + return dupes + + +class TokenMatcher: + def __init__(self, schools: list[dict]): + self._schools = schools + self._duplicates = _find_duplicates(schools) + self._norm_names = [normalize_text(s["school_name"]) for s in schools] + self._vectorizer = TfidfVectorizer(analyzer="word", ngram_range=(1, 2), min_df=1) + self._matrix = self._vectorizer.fit_transform(self._norm_names) + + def match( + self, message: str, translated: str = "" + ) -> tuple[dict | None, float, list[dict]]: + """ + Returns (best_match | None, best_score, top_k_candidates). + top_k_candidates is always populated when there's any signal — used by LLM tier. + """ + best_match: dict | None = None + best_score = 0.0 + best_candidates: list[dict] = [] + + for text in (message, translated): + query = normalize_text(text) + if not query: + continue + try: + query_vec = self._vectorizer.transform([query]) + except Exception: + continue + + scores = cosine_similarity(query_vec, self._matrix)[0] + top_indices = np.argsort(scores)[::-1][:TOP_K] + candidates = [ + (self._schools[idx], float(scores[idx])) + for idx in top_indices + if scores[idx] > 0.05 + ] + + if candidates and candidates[0][1] > best_score: + best_score = candidates[0][1] + best_match = candidates[0][0] + best_candidates = [c[0] for c in candidates] + + # Duplicate school names need LLM to disambiguate across mandals + is_duplicate = ( + best_match is not None + and normalize_text(best_match["school_name"]) in self._duplicates + ) + if is_duplicate: + best_score = min(best_score, HIGH_TOKEN_SCORE - 0.01) + + method = "TOKEN_HIGH" if best_score >= HIGH_TOKEN_SCORE else "TOKEN_LOW" + return best_match, best_score, best_candidates, method diff --git a/chatbot/cron_tasks/translation_cron.py b/chatbot/cron_tasks/translation_cron.py new file mode 100644 index 0000000..b574f0c --- /dev/null +++ b/chatbot/cron_tasks/translation_cron.py @@ -0,0 +1,34 @@ +import logging +from django.utils import timezone +from chatbot.scripts.guest_discussion.output.update_non_english_story import fix_guest_discussion_stories +from chatbot.scripts.mi_guest_flow.output.update_non_english_story import fix_guest_mi_story_stories +import os + +logger = logging.getLogger('django') + + +def handle_non_english_fix_cron(): + """ + Cron job to fix non-English content in Guest Discussion and Guest MI Story flows. + """ + try: + logger.info('=' * 70) + logger.info('🌐 Starting Non-English Content Fix Cron at: {}'.format(timezone.now())) + print(f"Cron running from cwd: {os.getcwd()}") + logger.info(f"Cron running from cwd: {os.getcwd()}") + + logger.info('🔧 Starting Guest Discussion non-English fix...') + discussion_result = fix_guest_discussion_stories() + logger.info(f"Guest Discussion CRON Fix Complete") + + # Fix Guest MI Story stories + logger.info('🔧 Starting Guest MI Story non-English fix...') + mi_story_result = fix_guest_mi_story_stories() + logger.info(f"Guest MI Story CRON Fix Complete") + + logger.info('=' * 70) + + logger.info('Non-English Content Fix Cron completed successfully at: {}'.format(timezone.now())) + + except Exception as e: + logger.exception("❌ Error during non-English content fix cron: %s", str(e)) diff --git a/chatbot/exceptions/story_exceptions.py b/chatbot/exceptions/story_exceptions.py new file mode 100644 index 0000000..b42a25e --- /dev/null +++ b/chatbot/exceptions/story_exceptions.py @@ -0,0 +1,17 @@ + + +class StoryError(Exception): + """Base story exception""" + code = "generic_error" + + +class StoryDomainError(StoryError): + code = "domain_error" + + +class StoryValidationError(StoryError): + code = "missing_fields" + + +class StorySaveError(StoryError): + code = "generic_error" diff --git a/chatbot/filter/__init__.py b/chatbot/filter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/filter/admin_filter.py b/chatbot/filter/admin_filter.py new file mode 100644 index 0000000..f7681f3 --- /dev/null +++ b/chatbot/filter/admin_filter.py @@ -0,0 +1,255 @@ +from django.contrib import admin +from django.db.models import Q + +from chatbot.models import Company, Profile, ProfileType +from chatbot.models.geo_models import ProfileAddress + + +class ProfileCompanyChatFilter(admin.SimpleListFilter): + title = 'Profile' + parameter_name = 'profile' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).only('company').first() + + if request.user.is_superuser: + return Profile.objects.all().values_list('id', 'first_name') + elif profile and profile.profile_type == ProfileType.MODERATOR: + company = profile.company + return Profile.objects.filter(company=company).values_list('id', 'first_name') + return [] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(Q(sender__id=self.value()) | Q(receiver__id=self.value())).prefetch_related( + 'sender__company', 'receiver__company') + return queryset + + +class ProfileEmailFilter(admin.SimpleListFilter): + title = 'ProfileEmail' + parameter_name = 'profile_email' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).only('company').first() + + if request.user.is_superuser: + return Profile.objects.all().values_list('id', 'email') + elif profile and profile.profile_type == ProfileType.MODERATOR: + company = profile.company + return Profile.objects.filter(company=company).values_list('id', 'email') + return [] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(Q(sender__id=self.value()) | Q(receiver__id=self.value())).prefetch_related( + 'sender__company', 'receiver__company') + return queryset + + +class CompanyChatCompanyFilter(admin.SimpleListFilter): + title = 'Company' + parameter_name = 'company' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).select_related('company').first() + + if request.user.is_superuser: + return Company.objects.values_list('id', 'name') + + if profile and profile.profile_type == ProfileType.MODERATOR: + return [(profile.company.id, profile.company.name)] + + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(Q(sender__company__id=self.value()) | Q(receiver__company__id=self.value())) + return queryset + + + +class ProfileCityFilter(admin.SimpleListFilter): + title = 'City' + parameter_name = 'profile_city' + + def lookups(self, request, model_admin): + fmch_company = Company.objects.filter(slug='fmch').only('id').first() + if not fmch_company: + return [] + + cities = ProfileAddress.objects.filter(profile__company=fmch_company).values_list('city', flat=True).distinct() + return [(city, city) for city in cities if city] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter( + Q(sender__profile_address__city=self.value()) | Q(receiver__profile_address__city=self.value()) + ).select_related('sender__profile_address', 'receiver__profile_address') + return queryset + + +class ProfileStateFilter(admin.SimpleListFilter): + title = 'State' + parameter_name = 'profile_state' + + def lookups(self, request, model_admin): + fmch_company = Company.objects.filter(slug='fmch').only('id').first() + if not fmch_company: + return [] + + states = ProfileAddress.objects.filter(profile__company=fmch_company).values_list('state', flat=True).distinct() + return [(state, state) for state in states if state] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter( + Q(sender__profile_address__state=self.value()) | Q(receiver__profile_address__state=self.value()) + ).select_related('sender__profile_address', 'receiver__profile_address') + return queryset + + +class ProfileCompanyFilter(admin.SimpleListFilter): + title = 'Company' + parameter_name = 'company' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return [(company.id, company.name) for company in Company.objects.all()] + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return [(profile[0].company.id, profile[0].company.name)] + else: + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(Q(company__id=self.value())) + + +class StoryCompanyFilter(admin.SimpleListFilter): + title = 'Company' + parameter_name = 'company' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return [(company.id, company.name) for company in Company.objects.all()] + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return [(profile[0].company.id, profile[0].company.name)] + else: + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(Q(author__company__id=self.value())) + + +class StoryStateFilter(admin.SimpleListFilter): + title = 'State' + parameter_name = 'state' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + states = ProfileAddress.objects.values_list('state', flat=True).distinct() + state_filters = [] + for state in states: + if state: + # Add as (value, display name) + state_filters.append((state, state)) + return state_filters + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + profile_address = ProfileAddress.objects.filter(profile=profile[0]).first() + if profile_address and profile_address.state: + return [(profile_address.state, profile_address.state)] + else: + return [] + else: + return [] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(author__profile_address__state=self.value()) + return queryset + + +class StoryDistrictFilter(admin.SimpleListFilter): + title = 'District' + parameter_name = 'district' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + districts = ProfileAddress.objects.values_list('district', flat=True).distinct() + district_filters = [] + for district in districts: + if district: + # Add as (value, display name) + district_filters.append((district, district)) + return district_filters + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + profile_address = ProfileAddress.objects.filter(profile=profile[0]).first() + if profile_address and profile_address.district: + return [(profile_address.district, profile_address.district)] + else: + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(author__profile_address__district=self.value()) + return queryset + + +class StoryBlockFilter(admin.SimpleListFilter): + title = 'Block' + parameter_name = 'block' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + blocks = ProfileAddress.objects.values_list('block', flat=True).distinct() + block_filters = [] + for block in blocks: + if block: + # Add as (value, display name) + block_filters.append((block, block)) + return block_filters + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + profile_address = ProfileAddress.objects.filter(profile=profile[0]).first() + if profile_address and profile_address.block: + return [(profile_address.block, profile_address.block)] + else: + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(author__profile_address__block=self.value()) + return queryset + + +class ChatSessionFilter(admin.SimpleListFilter): + title = 'company' + parameter_name = 'chat_session' + + def lookups(self, request, model_admin): + user_email = request.user.email + profile = Profile.objects.filter(email=user_email) + if request.user.is_superuser: + return [(company.id, company.name) for company in Company.objects.all()] + elif len(profile) > 0 and profile[0].profile_type == ProfileType.MODERATOR: + return [(profile[0].company.id, profile[0].company.name)] + else: + return [()] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(profile__company=self.value()) diff --git a/chatbot/filter/custom_date_from_filter.py b/chatbot/filter/custom_date_from_filter.py new file mode 100644 index 0000000..cadddbc --- /dev/null +++ b/chatbot/filter/custom_date_from_filter.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + + +class CustomAdvanceDateFilter(admin.SimpleListFilter): + """ + Neet to use this filter If we want to see the calendar based Date filter. Lookup function should have this value + so that the filter is visible (but the value can be anything in that format) + QuerySet is there just for override purpose. + """ + + + title = _('From Date') + parameter_name = 'created_at_from' + + def lookups(self, request, model_admin): + return [ + ('Pick From Date', _('Pick From Date')) + ] + + def queryset(self, request, queryset): + return None + diff --git a/chatbot/filter/drf_filter.py b/chatbot/filter/drf_filter.py new file mode 100644 index 0000000..05316fd --- /dev/null +++ b/chatbot/filter/drf_filter.py @@ -0,0 +1,15 @@ +from django.db.models import Q +from rest_framework import filters +from chatbot.models import CompanyChat + + +class ChatSessionProfileFilter(filters.BaseFilterBackend): + + def filter_queryset(self, request, queryset, view): + profile_id = request.query_params.get('profile') + if profile_id: + sessions = CompanyChat.objects.filter( + Q(sender__id=profile_id) | Q(receiver__id=profile_id) + ).values_list('session', flat=True).distinct() + return queryset.filter(session__in=sessions) + return queryset diff --git a/chatbot/filter/flow_filter.py b/chatbot/filter/flow_filter.py new file mode 100644 index 0000000..2490d7a --- /dev/null +++ b/chatbot/filter/flow_filter.py @@ -0,0 +1,16 @@ +from django.contrib.admin import SimpleListFilter + +from chatbot.models import SessionFlowName + + +class FlowFilter(SimpleListFilter): + title = 'Flow' + parameter_name = 'flow' + + def lookups(self, request, model_admin): + return [(choice.value, choice.label) for choice in SessionFlowName] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(other_params__flow=self.value()) + return queryset diff --git a/chatbot/filter/media_filters.py b/chatbot/filter/media_filters.py new file mode 100644 index 0000000..4ad899f --- /dev/null +++ b/chatbot/filter/media_filters.py @@ -0,0 +1,86 @@ +import django_filters +from django.db.models import Q +from chatbot.models.media_models import Media, FileTypeChoices, PriorityChoices + + +class MediaFilter(django_filters.FilterSet): + # Text search across multiple fields + search = django_filters.CharFilter(method='search_filter', label='Search') + + # Exact filters + media_type = django_filters.ChoiceFilter(choices=FileTypeChoices.choices) + priority = django_filters.ChoiceFilter(choices=PriorityChoices.choices) + + # Related filters + tag = django_filters.CharFilter(method='tag_filter', label='Tag name') + key = django_filters.CharFilter(method='key_filter', label='Key-Value key') + value = django_filters.CharFilter(method='value_filter', label='Key-Value value') + key_value = django_filters.CharFilter(method='key_value_filter', label='Key-Value pair (key:value)') + + # Date filters + created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte') + created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte') + + # Parent/child filters + parent_id = django_filters.NumberFilter(field_name='parent__id') + has_parent = django_filters.BooleanFilter(method='has_parent_filter') + has_children = django_filters.BooleanFilter(method='has_children_filter') + + # Company bot filter + company_bot = django_filters.NumberFilter(field_name='company_bot__id') + + class Meta: + model = Media + fields = ['media_type', 'priority', 'company_bot'] + + def search_filter(self, queryset, name, value): + """Full-text search across name, description, and extracted_text""" + if not value: + return queryset + + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(extracted_text__icontains=value) + ) + + def tag_filter(self, queryset, name, value): + """Filter by tag name""" + if not value: + return queryset + return queryset.filter(tags__name__icontains=value).distinct() + + def key_filter(self, queryset, name, value): + """Filter by key in key-value pairs""" + if not value: + return queryset + return queryset.filter(keyvalue__key__icontains=value).distinct() + + def value_filter(self, queryset, name, value): + """Filter by value in key-value pairs""" + if not value: + return queryset + return queryset.filter(keyvalue__value__icontains=value).distinct() + + def key_value_filter(self, queryset, name, value): + """Filter by key:value pair""" + if not value or ':' not in value: + return queryset + + key, val = value.split(':', 1) + return queryset.filter( + keyvalue__key__icontains=key.strip(), + keyvalue__value__icontains=val.strip() + ).distinct() + + def has_parent_filter(self, queryset, name, value): + """Filter media with/without parent""" + if value: + return queryset.exclude(parent__isnull=True) + return queryset.filter(parent__isnull=True) + + def has_children_filter(self, queryset, name, value): + """Filter media with/without children""" + if value: + return queryset.filter(media_set__isnull=False).distinct() + return queryset.filter(media_set__isnull=True) diff --git a/chatbot/filter/story_filter.py b/chatbot/filter/story_filter.py new file mode 100644 index 0000000..d0091aa --- /dev/null +++ b/chatbot/filter/story_filter.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from chatbot.models import Story + + +class UserNameFilter(admin.SimpleListFilter): + title = 'User Name' + parameter_name = 'user_name' + + def lookups(self, request, model_admin): + user_names = ( + Story.objects.exclude(other_params__user_name__isnull=True) + .values_list('other_params__user_name', flat=True) + .distinct() + ) + return [(name, name) for name in user_names if name] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(other_params__user_name=self.value()) + return queryset \ No newline at end of file diff --git a/chatbot/form/media/media_form.py b/chatbot/form/media/media_form.py new file mode 100644 index 0000000..bba9d5f --- /dev/null +++ b/chatbot/form/media/media_form.py @@ -0,0 +1,95 @@ +from django import forms +from django.contrib import admin +from chatbot.models.media_models import Media, Tag +from chatbot.models import TagSourceChoices, TagChoices + +BOT_PROFILE_ID = 1 + + +class MediaAdminForm(forms.ModelForm): + manual_tags = forms.ModelMultipleChoiceField( + queryset=Tag.objects.none(), + required=False, + widget=admin.widgets.FilteredSelectMultiple("Manual Tags", is_stacked=False) + ) + auto_tags = forms.ModelMultipleChoiceField( + queryset=Tag.objects.none(), + required=False, + widget=admin.widgets.FilteredSelectMultiple("Auto Tags", is_stacked=False), + # disabled=True + ) + + class Meta: + model = Media + fields = '__all__' + exclude = ['tags'] # Exclude the original tags field since we're handling it manually + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Manual tags: Include ALL manual tags from ALL organizations + self.fields['manual_tags'].queryset = Tag.objects.filter( + source_type=TagSourceChoices.MANUAL, + status=TagChoices.APPROVED + ).order_by('name') + # No company filter - shared across all organizations + + if getattr(self.instance, 'pk', None): + # Existing instance - set initial values + self.fields['manual_tags'].initial = self.instance.tags.filter( + source_type=TagSourceChoices.MANUAL + ) + + # Auto tags: Include ALL AI-extracted tags from ALL organizations + if hasattr(self.instance, '_auto_tags_to_preserve'): + auto_qs = self.instance._auto_tags_to_preserve + else: + auto_qs = self.instance.tags.filter( + source_type__in=[TagSourceChoices.AI_EXTRACTED, TagSourceChoices.AI_GENERATED] + ) + + if auto_qs.exists() if hasattr(auto_qs, 'exists') else auto_qs: + # Show all AI tags from all organizations in the dropdown + self.fields['auto_tags'].queryset = Tag.objects.filter( + source_type__in=[TagSourceChoices.AI_EXTRACTED, TagSourceChoices.AI_GENERATED], + status=TagChoices.APPROVED + ).order_by('name') + # No company filter - shared across all organizations + + self.fields['auto_tags'].initial = auto_qs + else: + self.fields.pop('auto_tags') + else: + # New object → hide auto_tags field + self.fields.pop('auto_tags', None) + + def save(self, commit=True): + # Save instance first to ensure it has an ID + instance = super().save(commit=False) + manual_tags = list(self.cleaned_data.get('manual_tags', [])) + print("manual_tags: ", manual_tags) + + if commit: + # For existing instances, preserve existing auto tags + auto_tags = list(instance.tags.filter( + source_type__in=[TagSourceChoices.AI_EXTRACTED, TagSourceChoices.AI_GENERATED] + )) + else: + auto_tags = list(self.cleaned_data.get('auto_tags', [])) + print("auto_tags: ", auto_tags) + + if commit: + print("Commit is True") + instance.save() # Now instance.pk exists + print("Cleaned Data: ", self.cleaned_data) + + # Set all tags (manual + auto) + instance.tags.set(manual_tags + auto_tags) + else: + print("Commit is False") + # Even if not committing, attach manual tags to the instance's m2m cache + instance._manual_tags_to_set = manual_tags + instance._auto_tags_to_preserve = auto_tags + + print("Instance: ", instance) + return instance diff --git a/chatbot/llm_models/__init__.py b/chatbot/llm_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/llm_models/llm_script.py b/chatbot/llm_models/llm_script.py new file mode 100644 index 0000000..c50437d --- /dev/null +++ b/chatbot/llm_models/llm_script.py @@ -0,0 +1,927 @@ +from botocore.client import Config as BotoConfig +from botocore.exceptions import ClientError +from chatbot.models import LLMModel +from chatbot.models.enums import LLMProvider +from chatbot.utils.llm import LLM +from typing import Optional, List, Dict +from django.core.validators import URLValidator +from openai import OpenAI +from pprint import pprint +from retrying import retry +from chatbot.models import LLMModel, Company +import boto3 +import json +import json_repair +import logging +import os +import requests +import traceback + + +logger = logging.getLogger('django') +validate = URLValidator() +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER')) + + +def handle_llama_model( + messages, max_token, model_name=None, is_json_format=True, temperature=None, top_p=None, seed=None, n=None, + stream=False, url_to_use=None +): + + if url_to_use: + url = url_to_use + else: + url = os.getenv('LLAMA_BASE_URL') + 'v1/chat/completions' + # finetune_url = os.getenv('LLAMAFINETUNE_BASE_URL') + 'v1/chat/completions' + + payload = { + "messages": messages, + "max_tokens": max_token, + } + + if model_name: + payload["model"] = model_name + else: + payload["model"] = LLMModel.LLAMA_3_1_8B_OPS + + if is_json_format: + payload["response_format"] = {"type": "json_object"} + if seed is not None: + payload["seed"] = seed + if n is not None: + payload["n"] = n + if top_p is not None: + payload["top_p"] = top_p + if temperature is not None: + payload["temperature"] = temperature + + headers = { + "Content-Type": "application/json" + } + response = requests.post( + url, + headers=headers, + data=json.dumps(payload), + stream=stream + ) + print(response.content) + response_str = str(response.content, encoding="utf-8") + + if is_json_format: + response_json = json.loads(response_str) + response_content = response_json['choices'][0]['message']['content'] + response_content = response_content.replace('\n', '').replace('\t', '').replace( + '\r', '').replace('\\n', '').replace('\\t', '').replace('\\r', '') + print("BEFORE LOADS: ", response_content) + response_json = json.loads(response_content) + return response_json + else: + return response_str + + +def handle_openai_model( + messages, max_token=None, temperature=None, company_bot=None, model_name=None, is_json_response=True, + stream=False, key_name='OPENAI_API_KEY', is_actual_key=False, tools=None, tool_choice=None, client_choice=None, + top_p=None, system_prompt=None +): + try: + if is_actual_key: + client_api_key = key_name + else: + client_api_key = os.getenv(key_name) + + if not client_api_key: + raise ValueError(f"No API key found for '{key_name}'. Please set the environment variable correctly.") + + if client_choice: + client = client_choice + else: + client = OpenAI(api_key=client_api_key) + + if model_name: + model_to_use = model_name + elif company_bot: + model_to_use = company_bot.llm_model + else: + model_to_use = LLMModel.GPT4_O_MINI + + if system_prompt and isinstance(system_prompt, list): + messages = system_prompt+messages + + request_data = { + "model": model_to_use, + "messages": messages, + } + token_limit_models = { + LLMModel.GPT5_MINI, + LLMModel.GPT5_2_PRO, + LLMModel.GPT5_2, + } + if max_token: + if company_bot.llm_model in token_limit_models: + request_data["max_completion_tokens"] = max_token + else: + request_data["max_tokens"]= max_token + if temperature: + request_data['temperature']= temperature + if is_json_response: + request_data["response_format"] = {"type": "json_object"} + if stream: + request_data["stream"] = stream + if tools: + request_data["tools"]= tools + if tool_choice: + request_data["tool_choice"]= tool_choice + if top_p is not None and company_bot.llm_model not in token_limit_models: + request_data['top_p'] = top_p + print("request_data: ", request_data) + response = client.chat.completions.create(**request_data) + price = calculate_and_log_llm_cost( + response=response, model_id=model_to_use, company_bot=company_bot + ) + print("raw res: ", response) + if is_json_response: + response_content = response.choices[0].message.content + response_json = None + if response_content: + response_json = json.loads(response_content) + return response_json + elif tools: + tool_calls = response.choices[0].message.tool_calls + if tool_calls and len(tool_calls) > 0: + return {} + return response.choices[0].message.content if response.choices else response + else: + return response.choices[0].message.content if response.choices else response + except Exception as e: + import traceback + traceback.print_exc() + raise + +def get_pricing_from_company_bot(company_bot, model_id): + try: + # Determine if company_bot is dict-like or an object (model instance) + if isinstance(company_bot, dict): + other_params = company_bot.get('other_params') + else: + # Model instance, has attribute + other_params = getattr(company_bot, 'other_params', None) + + if not other_params: + return None + + # Parse other_params if it is a string + if isinstance(other_params, str): + other_params_parsed = json.loads(other_params) + else: + other_params_parsed = other_params + + # Check if pricing data exists + pricing_data = other_params_parsed.get('model_pricing') + if not pricing_data: + logger.info(f"❌ No pricing_data key found in company bot other params.") + return None + + logger.info(f"🔍 Searching for model_id: '{model_id}'") + logger.info(f"🔍 Available pricing keys: {list(pricing_data.keys())}") + + # Get pricing for current model + model_pricing = pricing_data.get(model_id) + if not model_pricing: + logger.info(f"❌ No exact match found for: '{model_id}'") + model_pricing = pricing_data.get('llama3-3-70b') + + if model_pricing and 'input_cost_per_1k' in model_pricing and 'output_cost_per_1k' in model_pricing: + return { + 'input': float(model_pricing['input_cost_per_1k']), + 'output': float(model_pricing['output_cost_per_1k']) + } + + return None + + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e: + logger.error(f"Error parsing pricing from company_bot.other_params: {e}") + return None + +def retry_if_result_none(result): + return result is None + +@retry(stop_max_attempt_number=llm_retry_number, retry_on_result=retry_if_result_none, wrap_exception=True) +def handle_bedrock_model( + company_bot, system_prompt=None, messages=None, max_token=None, temperature=None, top_p=None, + model_name=None, region_name='us-west-2', tools=None, is_json_response=False, aws_key=None, + aws_secret_key=None, stop_sequences=None +): + # Support company_bot as either dict or model instance + if isinstance(company_bot, dict): + connect_timeout = company_bot.get('connect_timeout', 5.0) + read_timeout = company_bot.get('read_timeout', 10.0) + chat_history_limit = company_bot.get('chat_history_limit', 1000) + else: + connect_timeout = getattr(company_bot, 'connect_timeout', 5.0) + read_timeout = getattr(company_bot, 'read_timeout', 10.0) + chat_history_limit = getattr(company_bot, 'chat_history_limit', 1000) + + boto_config = BotoConfig( + connect_timeout=connect_timeout, + read_timeout=read_timeout, + retries={"mode": "adaptive"} + ) + + bedrock_runtime = boto3.client( + service_name='bedrock-runtime', + region_name=region_name, + aws_access_key_id=aws_key if aws_key else AWS_KEY, + aws_secret_access_key=aws_secret_key if aws_secret_key else AWS_SECRET_KEY, + config=boto_config + ) + print("aws_key used: ", aws_key if aws_key else AWS_KEY) + if model_name: + model_id = model_name + else: + model_id = 'meta.llama3-1-8b-instruct-v1:0' + + inference_config = {} + additional_model_fields = {} + + if max_token: + inference_config['maxTokens'] = max_token + if temperature is not None: + inference_config['temperature'] = temperature + if top_p: + inference_config['topP'] = top_p + if stop_sequences: + inference_config['stopSequences'] = stop_sequences + # Remove trailing assistant message + if messages and messages[-1]['role'] == 'assistant': + messages.pop() + # Enforce chat history rules + if messages: + # Find last user message + last_user_idx = None + for i in range(len(messages) - 1, -1, -1): + if messages[i]['role'] == 'user': + last_user_idx = i + break + + if last_user_idx is None: + messages = [] + else: + start_idx = max(0, last_user_idx - chat_history_limit) + + # Ensure first message is always a user + if messages[start_idx]['role'] != 'user': + for j in range(start_idx + 1, last_user_idx + 1): + if messages[j]['role'] == 'user': + start_idx = j + break + + messages = messages[start_idx:last_user_idx + 1] + + try: + request_payload = { + 'modelId': model_id, + 'messages': messages, + 'system': system_prompt, + } + if inference_config: + request_payload['inferenceConfig'] = inference_config + if tools: + print("tools: ", tools) + request_payload['toolConfig'] = tools.get('toolConfig') + + logger.info('Bedrock request payload: %s', request_payload) + response = bedrock_runtime.converse(**request_payload) + + logger.info('Conversation Bedrock response: %s', json.dumps(response)) + print('Conversation Bedrock response: ', response) + + usage_metrics = response.get('usage', {}) + if usage_metrics: + logger.info("--------------USAGE METRICS-------------") + input_tokens = usage_metrics.get('inputTokens', 0) + output_tokens = usage_metrics.get('outputTokens', 0) + total_tokens = usage_metrics.get('totalTokens', 0) + logger.info(f'💰 Token Usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}') + print(f'💰 Token Usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}') + + pricing = get_pricing_from_company_bot( + company_bot=company_bot, model_id=model_id + ) + if pricing: + input_cost = (input_tokens / 1000) * pricing['input'] + output_cost = (output_tokens / 1000) * pricing['output'] + total_cost = input_cost + output_cost + + logger.info( + f'💵 Model Cost - Input: ${input_cost:.6f} (${pricing["input"]}/1K), Output: ${output_cost:.6f} ' + f'(${pricing["output"]}/1K), Total: ${total_cost:.6f}') + print( + f'💵 Model Cost - Input: ${input_cost:.6f} (${pricing["input"]}/1K), Output: ${output_cost:.6f} ' + f'(${pricing["output"]}/1K), Total: ${total_cost:.6f}') + else: + logger.info('💵 No pricing data configured in company_bot.other_params') + print('💵 No pricing data configured in company_bot.other_params') + + # Log additional metrics if available + if 'stopReason' in response.get('stopReason', ''): + stop_reason = response.get('stopReason') + logger.info(f'🛑 Stop Reason: {stop_reason}') + print(f'🛑 Stop Reason: {stop_reason}') + else: + logger.info('⚠️ No usage metrics found in response') + print('⚠️ No usage metrics found in response') + + content_arr = response['output']['message']['content'] + content = content_arr[0] + content_tool = content.get('toolUse') + if content_tool: + if isinstance(content_tool, str): + final_output = json_repair.repair_json(content_tool, return_objects=True) + else: + final_output = content_tool + else: + content_text = content.get('text') + json_start = content_text.find('{') + if json_start != -1: + json_str = content_text[json_start:] + json_str = json_str.replace('\n', '').replace('\r', '').strip() + while json_str and (json_str.endswith("'") or json_str.endswith('"') or json_str.endswith(',')): + json_str = json_str[:-1].strip() + try: + final_output = json_repair.repair_json(json_str, return_objects=True) + logger.info('Loads final_output: %s', final_output) + except json.JSONDecodeError as e: + return None + elif is_json_response: + return None + else: + return content_text + + return final_output + except ClientError as e: + error_response = e.response + logger.error("❌ Bedrock ClientError:") + logger.error(f"Error Code: {error_response['Error']['Code']}") + logger.error(f"Error Message: {error_response['Error']['Message']}") + logger.error(f"Request ID: {error_response.get('ResponseMetadata', {}).get('RequestId')}") + print("❌ ClientError:") + print("Error Code:", error_response["Error"]["Code"]) + print("Error Message:", error_response["Error"]["Message"]) + print("Request ID:", error_response.get("ResponseMetadata", {}).get("RequestId")) + except Exception as e: + logger.error('Error processing request: %s', e, exc_info=True) + print(f'❌ Error processing Bedrock request: {e}') + return None + + +def get_file_metadata_from_vector_store(client, vector_store_ids, file_id): + """ + Fetch file metadata (attributes) from vector store. + Returns attributes dict or None if not found. + """ + if not vector_store_ids: + return None + + try: + # Try each vector store until we find the file + for vs_id in vector_store_ids: + try: + # Retrieve the vector store file object + vs_file = client.vector_stores.files.retrieve( + vector_store_id=vs_id, + file_id=file_id + ) + + # Check if attributes exist + if hasattr(vs_file, 'attributes') and vs_file.attributes: + logger.info(f"Found metadata for file {file_id} in vector store {vs_id}") + return vs_file.attributes + + except Exception as e: + # File not in this vector store, try next + logger.error(f"File {file_id} not found in vector store {vs_id}: {e}") + continue + + logger.info(f"No metadata found for file {file_id} in any vector store") + return None + + except Exception as e: + logger.error(f"Error fetching file metadata for {file_id}: {e}") + return None + + +def add_source_with_organization(source_entry, metadata): + """ + Adds 'url' and conditionally adds 'organization' if company exists in DB. + """ + if not metadata: + metadata = {} + # Add URL if present in metadata + if metadata and 'url' in metadata: + source_entry['url'] = metadata['url'] + + # Check for company slug in metadata + company_slug = metadata.get('company', 'shikshalokamstaging') + if company_slug: + try: + # Fetch company from database + company = Company.objects.filter(slug=company_slug).first() + if company: + source_entry['organization'] = { + 'name': company.name, + 'slug': company.slug + } + logger.info(f"Added organization info for company: {company.name}") + else: + logger.info(f"Company with slug '{company_slug}' not found in database") + except Exception as e: + logger.error(f"Error fetching company with slug '{company_slug}': {e}") + + return source_entry + + +def calculate_and_log_llm_cost(*, response, model_id, company_bot=None, provider="openai"): + """ + Generic cost calculator for OpenAI-style responses. + Supports: + - Chat Completions API + - Responses API + - Streaming + non-streaming + """ + + if not response or not company_bot: + return None + + usage = getattr(response, "usage", None) + if not usage: + logger.info("⚠️ LLM response has no usage data") + return None + + # --- Normalize token fields across APIs --- + input_tokens = ( + getattr(usage, "input_tokens", None) # Responses API + or getattr(usage, "prompt_tokens", 0) # Chat Completions + ) + + output_tokens = ( + getattr(usage, "output_tokens", None) # Responses API + or getattr(usage, "completion_tokens", 0) # Chat Completions + ) + + total_tokens = ( + getattr(usage, "total_tokens", None) + or (input_tokens + output_tokens) + ) + + logger.info( + f"💰 {provider.upper()} Tokens — " + f"Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}" + ) + print( + f"💰 {provider.upper()} Tokens — " + f"Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}" + ) + + pricing = get_pricing_from_company_bot( + company_bot=company_bot, + model_id=model_id + ) + + if not pricing: + logger.info(f"💵 No pricing configured for {provider} model: {model_id}") + print(f"💵 No pricing configured for {provider} model: {model_id}") + return None + + input_cost = (input_tokens / 1000) * pricing["input"] + output_cost = (output_tokens / 1000) * pricing["output"] + total_cost = input_cost + output_cost + + logger.info( + f"💵 {provider.upper()} Cost — " + f"Input: ${input_cost:.6f}, " + f"Output: ${output_cost:.6f}, " + f"Total: ${total_cost:.6f}" + ) + print( + f"💵 {provider.upper()} Cost — " + f"Input: ${input_cost:.6f}, " + f"Output: ${output_cost:.6f}, " + f"Total: ${total_cost:.6f}" + ) + + return { + "provider": provider, + "model_id": model_id, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "input_cost": input_cost, + "output_cost": output_cost, + "total_cost": total_cost, + } + + +def calculate_and_log_openai_cost(*, response, model_id, company_bot=None): + """ + Calculates and logs OpenAI cost using Responses API usage. + Works for both streaming and non-streaming responses. + """ + + if not response or not company_bot: + return None + + usage = getattr(response, "usage", None) + if not usage: + logger.info("⚠️ OpenAI response has no usage data") + return None + + input_tokens = usage.input_tokens or 0 + output_tokens = usage.output_tokens or 0 + total_tokens = usage.total_tokens or (input_tokens + output_tokens) + + logger.info( + f"💰 OpenAI Tokens — Input: {input_tokens}, " + f"Output: {output_tokens}, Total: {total_tokens}" + ) + print( + f"💰 OpenAI Tokens — Input: {input_tokens}, " + f"Output: {output_tokens}, Total: {total_tokens}" + ) + + pricing = get_pricing_from_company_bot( + company_bot=company_bot, + model_id=model_id + ) + + if not pricing: + logger.info(f"💵 No pricing configured for OpenAI model: {model_id}") + print(f"💵 No pricing configured for OpenAI model: {model_id}") + return None + + input_cost = (input_tokens / 1000) * pricing["input"] + output_cost = (output_tokens / 1000) * pricing["output"] + total_cost = input_cost + output_cost + + logger.info( + f"💵 OpenAI Cost — Input: ${input_cost:.6f}, Output: ${output_cost:.6f}, Total: ${total_cost:.6f}" + ) + + print( + f"💵 OpenAI Cost — Input: ${input_cost:.6f}, Output: ${output_cost:.6f}, Total: ${total_cost:.6f}" + ) + + return { + "model_id": model_id, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "input_cost": input_cost, + "output_cost": output_cost, + "total_cost": total_cost, + } + + +def handle_openai_response_api( + messages, system_prompt=None, max_token=None, temperature=None, company_bot=None, + model_name=None, key_name='OPENAI_API_KEY', is_actual_key=False, + top_p=None, tool_choice="auto", tools: Optional[List[Dict]] = None, stream=False, + is_json_response=False +): + """ + OpenAI Responses API with file_search and function calling support. + + This uses the new Responses API (client.responses.create) which supports: + - file_search tool with vector stores + - Function calling (e.g., for file downloads) + - Streaming and non-streaming responses + - Unified interface for chat + tools + """ + if is_actual_key: + client_api_key = key_name + else: + client_api_key = os.getenv(key_name) + + if not client_api_key: + yield { + 'error': f"No API key found for '{key_name}'. Please set the environment variable correctly.", + 'finish_reason': 'error' + } + return + + client = OpenAI(api_key=client_api_key) + + if model_name: + model_to_use = model_name + elif company_bot: + model_to_use = company_bot.llm_model + else: + model_to_use = LLMModel.GPT4_O_MINI + + # Build input array for Responses API (includes system + conversation messages) + input_messages = [] + + # Add system prompt as first message + if system_prompt: + if isinstance(system_prompt, list): + # Extract system content from list format + for msg in system_prompt: + if msg.get('role') == 'system': + input_messages.append({ + 'role': 'system', + 'content': msg.get('content', '') + }) + elif isinstance(system_prompt, str): + input_messages.append({ + 'role': 'system', + 'content': system_prompt + }) + + # Enforce chat history rules (similar to bedrock) + if messages and company_bot and hasattr(company_bot, 'chat_history_limit') and company_bot.chat_history_limit: + # Remove trailing assistant message + if messages[-1]['role'] == 'assistant': + messages = messages[:-1] + + # Find last user message + last_user_idx = None + for i in range(len(messages) - 1, -1, -1): + if messages[i]['role'] == 'user': + last_user_idx = i + break + + if last_user_idx is None: + messages = [] + else: + start_idx = max(0, last_user_idx - company_bot.chat_history_limit) + + # Ensure first message is always a user + if messages[start_idx]['role'] != 'user': + for j in range(start_idx + 1, last_user_idx + 1): + if messages[j]['role'] == 'user': + start_idx = j + break + + messages = messages[start_idx:last_user_idx + 1] + + # Add conversation messages + input_messages.extend(messages) + + # Build request data for Responses API + # Note: client.responses.stream() doesn't take 'stream' parameter - it streams by default + request_data = { + "model": model_to_use, + "input": input_messages + } + + if max_token: + request_data["max_output_tokens"] = max_token + if temperature is not None: + request_data['temperature'] = temperature + if top_p: + request_data['top_p'] = top_p + if is_json_response: + request_data["response_format"] = {"type": "json_object"} + + + # Add tools to request if provided (already parsed by caller) + if tools: + request_data["tools"] = tools + request_data["tool_choice"] = tool_choice + logger.info(f"Using tools configuration: {len(tools) if isinstance(tools, list) else 'single tool'}") + else: + logger.info("⚠️ No tools provided") + + logger.info("Responses API %s request: %s", "streaming" if stream else "non-streaming", request_data) + + # Extract vector_store_ids from tools for metadata fetching + vector_store_ids = [] + if tools: + for tool in (tools if isinstance(tools, list) else [tools]): + if tool.get('type') == 'file_search' and tool.get('vector_store_ids'): + vector_store_ids.extend(tool.get('vector_store_ids')) + + try: + if stream: + # Use Responses API with streaming context manager + sources = [] + seen_file_ids = set() + function_call_name = None + function_call_args = "" + function_call_id = None + function_call_complete = False + + with client.responses.stream(**request_data) as response_stream: + for event in response_stream: + logger.info(f"OpenAI Event: {event.type} | Data: {event}") + + # Handle function call delta events (streaming function arguments character-by-character) + if event.type == 'response.function_call_arguments.delta': + # Accumulate function arguments using snapshot (complete JSON so far) + function_call_args = event.snapshot + if not function_call_id: + function_call_id = event.item_id + logger.info(f"Function call delta: snapshot length = {len(function_call_args)} chars") + print(f"📝 Function args snapshot: {function_call_args[:100]}...") + + # Handle function call done event + elif event.type == 'response.function_call_arguments.done': + function_call_complete = True + logger.info(f"Function call arguments complete: {len(function_call_args)} chars") + print(f"✅ Function call arguments COMPLETE") + + # Yield function call response with sources + # This will be processed by common_handler to extract content and send to user + yield { + 'function_call': { + 'name': function_call_name, + 'arguments': function_call_args # JSON string with filename and content + }, + 'finish_reason': 'function_call', # Internal marker for common_handler + 'extra_content': { + 'sources': sources # Include sources collected from annotations + } + } + + # Handle function call output item to get function name + elif event.type == 'response.output_item.added' and hasattr(event, 'item'): + if hasattr(event.item, 'type') and event.item.type == 'function_call': + function_call_name = event.item.name + function_call_id = event.item.id + logger.info(f"Function call detected: {function_call_name}") + print(f"🎯 Function name: {function_call_name}") + + # Handle file citation annotations + if event.type == 'response.output_text.annotation.added' and event.annotation[ + "type"] == 'file_citation': + file_id = event.annotation["file_id"] + if file_id not in seen_file_ids: + source_entry = { + "source_id": file_id, + "title": event.annotation["filename"] + } + + # Fetch metadata from vector store + metadata = get_file_metadata_from_vector_store(client, vector_store_ids, file_id) + + # Enrich source with organization info + source_entry = add_source_with_organization(source_entry, metadata) + + sources.append(source_entry) + seen_file_ids.add(file_id) + + # Handle ResponseTextDeltaEvent - extract incremental delta + elif event.type == 'response.output_text.delta': + content_chunk = event.delta or "" + yield { + 'content': content_chunk, + 'finish_reason': None + } + + # Handle ResponseTextDoneEvent - text output completed + elif event.type == 'response.output_text.done': + yield { + 'content': '', + 'finish_reason': 'stop', + 'extra_content': { + "sources": sources + } + } + + # Handle ResponseCompletedEvent - full response finished + elif event.type == 'response.completed': + logger.info(f"Response completed") + price = calculate_and_log_openai_cost( + response=event.response, model_id=model_to_use, company_bot=company_bot + ) + break + + # Handle error events + elif event.type == 'error': + error_msg = getattr(event, 'error', {}).get('message', 'Unknown error') + logger.error(f"Stream error: {error_msg}") + yield { + 'content': '', + 'error': error_msg, + 'finish_reason': 'error' + } + break + else: + # Non-streaming mode - get complete response at once + response = client.responses.create(**request_data) + print("Openai response: ", response) + logger.info(f"Openai response: {response}") + + price = calculate_and_log_openai_cost( + response=response, model_id=model_to_use, company_bot=company_bot + ) + + logger.info(f"free-flows response: {response}") + logger.info("Non-streaming response received") + # Extract full text from response + # The response.output is a list that may contain: + # - ResponseFileSearchToolCall (tool calls) + # - ResponseOutputMessage (actual text messages) + full_text = "" + sources = [] + seen_file_ids = set() + + if hasattr(response, 'output') and response.output: + for output_item in response.output: + # Check if this is a ResponseOutputMessage (has 'content' attribute) + if hasattr(output_item, 'content') and output_item.content: + # The content is a list of ResponseOutputText objects + for content_item in output_item.content: + if hasattr(content_item, 'text'): + full_text += content_item.text + + # Extract sources from annotations (deduplicate by file_id) + if hasattr(content_item, 'annotations') and content_item.annotations: + for annotation in content_item.annotations: + if annotation.type == 'file_citation' and annotation.file_id not in seen_file_ids: + source_entry = { + "source_id": annotation.file_id, + "title": annotation.filename + } + # Fetch metadata from vector store + metadata = get_file_metadata_from_vector_store(client, vector_store_ids, + annotation.file_id) + + # Enrich source with organization info + source_entry = add_source_with_organization(source_entry, metadata) + sources.append(source_entry) + seen_file_ids.add(annotation.file_id) + + logger.info(f"Extracted text length: {len(full_text)} chars, unique sources: {len(sources)}") + + # Yield single complete response + yield { + 'content': full_text, + 'finish_reason': 'stop', + 'extra_content': { + "sources": sources + } + } + except Exception as e: + error_msg = f"Error during Responses API {'streaming' if stream else 'call'}: {str(e)}" + logger.error('Error: %s', e, exc_info=True) + print(error_msg) + yield { + 'content': '', + 'error': error_msg, + 'finish_reason': 'error' + } + + +def handle_bedrock_invoke_model( + messages=None, max_token=None, temperature=None, top_p=None, + model_name=None, region_name='us-west-2', tools=None +): + + if model_name: + model_id = model_name + else: + model_id = 'meta.llama3-1-8b-instruct-v1:0' + + print("USING MODEL ID: ", model_id) + + print("Messages: ", messages) + try: + + body = json.dumps({ + "prompt": json.dumps(messages), + "max_gen_len": max_token, + "temperature": temperature, + "top_p": top_p + }) + + bedrock_runtime = boto3.client( + service_name='bedrock-runtime', + region_name=region_name, + aws_access_key_id=AWS_KEY, + aws_secret_access_key=AWS_SECRET_KEY + ) + + response = bedrock_runtime.invoke_model( + body=body, + modelId=model_id, + accept="application/json", + contentType="application/json" + ) + + a = response.get('body').read() + b = a.decode('utf-8') + response_body = json.loads(b) + print(response_body) + print(type(response_body)) + + result = response_body.get('generation', '') + print("\nResult:\n\t", result) + + + return result + + except Exception as e: + print(f"Error processing request: {e}") diff --git a/chatbot/llm_models/story_tools.py b/chatbot/llm_models/story_tools.py new file mode 100644 index 0000000..af060ad --- /dev/null +++ b/chatbot/llm_models/story_tools.py @@ -0,0 +1,111 @@ + + +def get_end_story_tools(): + tool = { + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "get_story_output", + "description": "Generate a detailed narrative output in a valid JSON format containing specific fields.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the story" + }, + "objective": { + "type": "string", + "description": "Objective of the micro improvement" + }, + "action_steps": { + "type": "string", + "description": "5 Action steps taken by the user to implement the micro improvement" + }, + "impact": { + "type": "string", + "description": "Impact created from this micro improvement" + }, + "micro_improvement": { + "type": "string", + "description": "Why is this micro-improvement important" + }, + "resource_name": { + "type": "string", + "description": "Learning resources name that you want the stakeholders to see while doing the project" + }, + "resource_link": { + "type": "string", + "description": "Learning resources link that you want the stakeholders to see while doing the project" + }, + "duration": { + "type": "string", + "description": "Total time span of the project, from start to end" + }, + "keywords": { + "type": "string", + "description": "Keywords improve search ability, tag this Improvement project with appropriate keywords" + }, + "status": { + "type": "string", + "description": "The current state of the project, such as 'STARTED,' 'inPROGRESS,' or 'SUBMITTED'" + }, + "project_start_date": { + "type": "string", + "description": "Starting date of the project if any" + }, + "project_end_date": { + "type": "string", + "description": "Completion date of project if any" + }, + "problem_statement": { + "type": "string", + "description": "The challenge faced by the user and what they wanted to solve" + } + }, + "required": ["title", "objective", "action_steps", "impact", "micro_improvement", + "duration", "status", "problem_statement"] + } + } + } + } + ] + } + } + + return tool + + +def get_story_content_tools(): + tool = { + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "get_story_output", + "description": "Generate a detailed narrative output in a valid JSON format containing specific fields.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "NARRATIVE. MAKE SURE THE NARRATIVE GENERATED IS BETWEEN 500-600 WORDS" + }, + "blurb": { + "type": "string", + "description": "first-person summary (2–3 sentences) from the user’s perspective" + } + }, + "required": ["content", "blurb"] + } + } + } + } + ] + } + } + + return tool diff --git a/chatbot/management/__init__.py b/chatbot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/management/commands/__init__.py b/chatbot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/management/commands/create_schemas.py b/chatbot/management/commands/create_schemas.py new file mode 100644 index 0000000..e7be98d --- /dev/null +++ b/chatbot/management/commands/create_schemas.py @@ -0,0 +1,135 @@ +""" +Django management command to automatically create PostgreSQL schemas if they don't exist. + +Usage: + python manage.py create_schemas + +Environment Variables: + POSTGRES_SCHEMAS: Comma-separated list of schema names to create (e.g., "schema1,schema2,schema3") +""" + +import os +from django.core.management.base import BaseCommand, CommandError +from django.db import connection + + +class Command(BaseCommand): + help = 'Creates PostgreSQL schemas if they do not exist' + + def add_arguments(self, parser): + """ + Add custom command arguments + """ + parser.add_argument( + '--schemas', + type=str, + help='Comma-separated list of schema names to create (overrides POSTGRES_SCHEMAS env variable)', + ) + + def handle(self, *args, **options): + """ + Main command handler to create schemas + """ + # Get schemas from command argument or environment variable + schemas_str = options.get('schemas') or os.getenv('POSTGRES_SCHEMAS', '') or ["shikshalokam"] + + if not schemas_str: + self.stdout.write( + self.style.WARNING( + 'No schemas specified. Please set POSTGRES_SCHEMAS environment variable ' + 'or use --schemas argument.' + ) + ) + return + + # Parse schema names + schemas = [s.strip() for s in schemas_str.split(',') if s.strip()] + + if not schemas: + self.stdout.write( + self.style.WARNING('No valid schema names found.') + ) + return + + self.stdout.write(f'Attempting to create {len(schemas)} schema(s)...\n') + + # Create each schema + created_count = 0 + skipped_count = 0 + error_count = 0 + + with connection.cursor() as cursor: + for schema_name in schemas: + try: + # Validate schema name (basic SQL injection prevention) + if not self._is_valid_schema_name(schema_name): + self.stdout.write( + self.style.ERROR( + f' ✗ Invalid schema name: {schema_name}' + ) + ) + error_count += 1 + continue + + # Check if schema exists + cursor.execute( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = %s", + [schema_name] + ) + exists = cursor.fetchone() + + if exists: + self.stdout.write( + self.style.WARNING( + f' ⊙ Schema already exists: {schema_name}' + ) + ) + skipped_count += 1 + else: + # Create schema + cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"') + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created schema: {schema_name}' + ) + ) + created_count += 1 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error creating schema {schema_name}: {str(e)}' + ) + ) + error_count += 1 + + # Summary + self.stdout.write('\n' + '=' * 50) + self.stdout.write(self.style.SUCCESS(f'Created: {created_count}')) + self.stdout.write(self.style.WARNING(f'Skipped (already exist): {skipped_count}')) + if error_count > 0: + self.stdout.write(self.style.ERROR(f'Errors: {error_count}')) + self.stdout.write('=' * 50) + + if error_count > 0: + raise CommandError(f'Failed to create {error_count} schema(s)') + + def _is_valid_schema_name(self, schema_name): + """ + Validate schema name to prevent SQL injection + + Args: + schema_name (str): Schema name to validate + + Returns: + bool: True if valid, False otherwise + """ + # Schema names should only contain alphanumeric characters, underscores, and hyphens + # and should not be empty or exceed reasonable length + if not schema_name or len(schema_name) > 63: # PostgreSQL identifier length limit + return False + + import re + # Allow alphanumeric, underscores, and hyphens only + return bool(re.match(r'^[a-zA-Z0-9_-]+$', schema_name)) + diff --git a/chatbot/management/commands/prepare_db.py b/chatbot/management/commands/prepare_db.py new file mode 100644 index 0000000..0688c5b --- /dev/null +++ b/chatbot/management/commands/prepare_db.py @@ -0,0 +1,443 @@ +""" +Django management command to prepare the database: +- Create PostgreSQL schemas if they don't exist +- Create/update Company record with id=1 +- Create/update Profile record with id=1 + +Usage: + python manage.py prepare_db + +Environment Variables: + POSTGRES_SCHEMAS: Comma-separated list of schema names to create (e.g., "schema1,schema2,schema3") + COMPANY_NAME: Company name (default: "Shikshalokam") + COMPANY_SLUG: Company slug (default: "shikshalokam") + ADMIN_EMAIL: Admin email (default: "accounts@gritworks.ai") +""" + +import os +import re +from django.core.management.base import BaseCommand, CommandError +from django.core.management import call_command +from django.db import connection +from chatbot.models.company_models import Company +from chatbot.models.profile_models import Profile +from chatbot.models.enums import EntityStatus, ProfileType + + +class Command(BaseCommand): + help = 'Prepares database by creating schemas, company, and admin profile' + + def add_arguments(self, parser): + """ + Add custom command arguments + """ + parser.add_argument( + '--schemas', + type=str, + help='Comma-separated list of schema names to create (overrides POSTGRES_SCHEMAS env variable)', + ) + + def handle(self, *args, **options): + """ + Main command handler to prepare database + """ + self.stdout.write(self.style.SUCCESS('\n' + '=' * 60)) + self.stdout.write(self.style.SUCCESS('Starting Database Preparation')) + self.stdout.write(self.style.SUCCESS('=' * 60 + '\n')) + + # Track overall status + total_errors = 0 + + # Step 1: Create schemas + schema_errors = self._create_schemas(options) + total_errors += schema_errors + + migration_errors = self._migrate_database() + total_errors += migration_errors + + + # Step 2: Create/update Company + company_errors = self._setup_company() + total_errors += company_errors + + # Step 3: Create/update Profile + profile_errors = self._setup_profile() + total_errors += profile_errors + + self._setup_profile_null_user() + + # Final summary + self.stdout.write('\n' + '=' * 60) + self.stdout.write(self.style.SUCCESS('Database Preparation Complete')) + if total_errors > 0: + self.stdout.write(self.style.ERROR(f'Total Errors: {total_errors}')) + else: + self.stdout.write(self.style.SUCCESS('All operations completed successfully!')) + self.stdout.write(self.style.SUCCESS('=' * 60)) + + if total_errors > 0: + raise CommandError(f'Database preparation completed with {total_errors} error(s)') + + def _create_schemas(self, options): + """ + Create PostgreSQL schemas if they don't exist + + Returns: + int: Number of errors encountered + """ + self.stdout.write(self.style.HTTP_INFO('\n[1/4] Creating PostgreSQL Schemas')) + self.stdout.write('-' * 60) + + # Get schemas from command argument or environment variable + schemas_str = options.get('schemas') or os.environ.get('POSTGRES_SCHEMAS', '') or ['shikshalokam'] + + if not schemas_str: + self.stdout.write( + self.style.WARNING( + ' ⊙ No schemas specified (POSTGRES_SCHEMAS not set). Skipping...' + ) + ) + return 0 + + # Parse schema names + schemas = [s.strip() for s in schemas_str.split(',') if s.strip()] + + if not schemas: + self.stdout.write( + self.style.WARNING(' ⊙ No valid schema names found. Skipping...') + ) + return 0 + + self.stdout.write(f' Attempting to create {len(schemas)} schema(s)...\n') + + # Create each schema + created_count = 0 + skipped_count = 0 + error_count = 0 + + with connection.cursor() as cursor: + for schema_name in schemas: + try: + # Validate schema name + if not self._is_valid_schema_name(schema_name): + self.stdout.write( + self.style.ERROR( + f' ✗ Invalid schema name: {schema_name}' + ) + ) + error_count += 1 + continue + + # Check if schema exists + cursor.execute( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = %s", + [schema_name] + ) + exists = cursor.fetchone() + + if exists: + self.stdout.write( + self.style.WARNING( + f' ⊙ Schema already exists: {schema_name}' + ) + ) + skipped_count += 1 + else: + # Create schema + cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"') + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created schema: {schema_name}' + ) + ) + created_count += 1 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error creating schema {schema_name}: {str(e)}' + ) + ) + error_count += 1 + + # Schema summary + self.stdout.write(f'\n Summary: Created: {created_count}, Skipped: {skipped_count}, Errors: {error_count}') + return error_count + + def _migrate_database(self): + """ + Migrate the database by running Django's migrate command programmatically. + + Returns: + int: Number of errors encountered (0 or 1) + """ + self.stdout.write(self.style.HTTP_INFO('\n[2/4] Migrating the database')) + self.stdout.write('-' * 60) + + try: + self.stdout.write(' Running migrations...\n') + + call_command('migrate', interactive=False, verbosity=1) + + self.stdout.write( + self.style.SUCCESS( + ' ✓ Database migrations applied successfully' + ) + ) + return 0 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error migrating database: {str(e)}' + ) + ) + return 1 + + def _setup_company(self): + """ + Create or update Company record with id=1 + + Returns: + int: Number of errors encountered (0 or 1) + """ + self.stdout.write(self.style.HTTP_INFO('\n[3/4] Setting up Company Record')) + self.stdout.write('-' * 60) + + try: + # Get environment variables + company_name = os.environ.get('COMPANY_NAME', 'Shikshalokam') + company_slug = os.environ.get('COMPANY_SLUG', 'shikshalokam') + + self.stdout.write(f' Company Name: {company_name}') + self.stdout.write(f' Company Slug: {company_slug}\n') + + # Check if Company with id=1 exists + try: + company = Company.objects.get(id=1) + # Update existing company + company.name = company_name + company.slug = company_slug + company.save() + self.stdout.write( + self.style.SUCCESS( + f' ✓ Updated Company (id=1): {company_name}' + ) + ) + except Company.DoesNotExist: + # Create new company with id=1 + company = Company.objects.create( + id=1, + name=company_name, + slug=company_slug, + status=EntityStatus.ACTIVE, + logo=None, + url=None + ) + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created Company (id=1): {company_name}' + ) + ) + + return 0 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error setting up Company: {str(e)}' + ) + ) + return 1 + + def _setup_profile(self): + """ + Create or update Profile record with id=1 + + Returns: + int: Number of errors encountered (0 or 1) + """ + self.stdout.write(self.style.HTTP_INFO('\n[4/4] Setting up Admin Profile')) + self.stdout.write('-' * 60) + + try: + # Get environment variable + admin_email = os.environ.get('ADMIN_EMAIL', 'admin@shikshalokam.org') + admin_first_name = 'AI' + + self.stdout.write(f' Admin Email: {admin_email}') + self.stdout.write(f' Admin First Name: {admin_first_name}\n') + + # Ensure Company with id=1 exists + try: + company = Company.objects.get(id=1) + except Company.DoesNotExist: + self.stdout.write( + self.style.ERROR( + ' ✗ Company with id=1 does not exist. Cannot create Profile.' + ) + ) + return 1 + + # Check if Profile with id=1 exists + try: + profile = Profile.objects.get(id=1) + # Update existing profile + profile.first_name = admin_first_name + profile.email = admin_email + profile.company = company + profile.save() + self.stdout.write( + self.style.SUCCESS( + f' ✓ Updated Profile (id=1): {admin_email}' + ) + ) + except Profile.DoesNotExist: + # Create new profile with id=1 + profile = Profile.objects.create( + id=1, + first_name=admin_first_name, + email=admin_email, + company=company, + status=EntityStatus.ACTIVE, + profile_type=ProfileType.USER, + last_name=None, + phone=None, + alternate_phone=None, + country=None, + password=None, + profile_code=None, + location=None, + caste=None, + gender=None, + designation='', + org_associated=None, + product_interested=None, + company_spoc=None, + other_params=None, + source=None, + preferred_route=None, + userid=None, + latest_flow_used=None + ) + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created Profile (id=1): {admin_email}' + ) + ) + + return 0 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error setting up Profile: {str(e)}' + ) + ) + return 1 + + def _setup_profile_null_user(self): + """ + Create or update Profile record with id=1 + + Returns: + int: Number of errors encountered (0 or 1) + """ + self.stdout.write(self.style.HTTP_INFO('\n[4/4] Setting up Null User Profile')) + self.stdout.write('-' * 60) + + try: + # Get environment variable + null_user_email = "null@shikshalokam.org" + null_user_first_name = 'Null User' + company_name = os.environ.get('COMPANY_SLUG', 'shikshalokamstaging') + + self.stdout.write(f' Null User Email: {null_user_email}') + self.stdout.write(f' Null User First Name: {null_user_first_name}\n') + + # Ensure Company with id=1 exists + try: + company = Company.objects.get(slug=company_name) + except Company.DoesNotExist: + self.stdout.write( + self.style.ERROR( + ' ✗ Company with id=1 does not exist. Cannot create Profile.' + ) + ) + return 1 + + # Check if Profile with id=1 exists + try: + profile = Profile.objects.get(email=null_user_email) + # Update existing profile + profile.first_name = null_user_first_name + profile.email = null_user_email + profile.company = company + profile.password = "grit@123" + profile.save() + self.stdout.write( + self.style.SUCCESS( + f' ✓ Updated Profile ({null_user_email}): {null_user_email}' + ) + ) + except Profile.DoesNotExist: + profile = Profile.objects.create( + first_name=null_user_first_name, + email=null_user_email, + company=company, + status=EntityStatus.ACTIVE, + profile_type=ProfileType.USER, + last_name=None, + phone=None, + alternate_phone=None, + country=None, + password="grit@123", + profile_code=None, + location=None, + caste=None, + gender=None, + designation='', + org_associated=None, + product_interested=None, + company_spoc=None, + other_params=None, + source=None, + preferred_route=None, + userid=None, + latest_flow_used=None + ) + self.stdout.write( + self.style.SUCCESS( + f' ✓ Created Profile : {null_user_email}' + ) + ) + + return 0 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ Error setting up Profile: {str(e)}' + ) + ) + return 1 + + + def _is_valid_schema_name(self, schema_name): + """ + Validate schema name to prevent SQL injection + + Args: + schema_name (str): Schema name to validate + + Returns: + bool: True if valid, False otherwise + """ + # Schema names should only contain alphanumeric characters, underscores, and hyphens + # and should not be empty or exceed reasonable length + if not schema_name or len(schema_name) > 63: # PostgreSQL identifier length limit + return False + + # Allow alphanumeric, underscores, and hyphens only + return bool(re.match(r'^[a-zA-Z0-9_-]+$', schema_name)) + diff --git a/chatbot/middlewares/VerifyAuthToken.py b/chatbot/middlewares/VerifyAuthToken.py new file mode 100644 index 0000000..34a0ab6 --- /dev/null +++ b/chatbot/middlewares/VerifyAuthToken.py @@ -0,0 +1,49 @@ +import traceback +import jwt +from django.http import JsonResponse + +class VerifyAuthToken: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + auth_header = request.META.get('HTTP_AUTHORIZATION') + + AUTH_ERROR_MESSAGE = "Invalid token" + + if auth_header: + # Trim "Bearer " prefix + if auth_header.startswith('Bearer '): + token = auth_header[7:] # Remove "Bearer " (7 characters) + else: + return JsonResponse( + {'error': AUTH_ERROR_MESSAGE}, + status=401 + ) + + # Authenticate the JWT token + try: + # Decode the JWT token + jwt.decode( token, options={"verify_signature": False}) + + except jwt.ExpiredSignatureError: + return JsonResponse( + {'error': AUTH_ERROR_MESSAGE}, + status=401 + ) + except jwt.InvalidTokenError: + return JsonResponse( + {'error': AUTH_ERROR_MESSAGE}, + status=401 + ) + except Exception as e: + traceback.print_exc() + return JsonResponse( + {'error': f'Authentication failed'}, + status=500 + ) + + # If no Authorization header, allow the request to proceed + response = self.get_response(request) + return response \ No newline at end of file diff --git a/chatbot/middlewares/__init__.py b/chatbot/middlewares/__init__.py new file mode 100644 index 0000000..c481762 --- /dev/null +++ b/chatbot/middlewares/__init__.py @@ -0,0 +1 @@ +from chatbot.middlewares.VerifyAuthToken import VerifyAuthToken \ No newline at end of file diff --git a/chatbot/migrations/0001_initial.py b/chatbot/migrations/0001_initial.py new file mode 100644 index 0000000..7dde6d4 --- /dev/null +++ b/chatbot/migrations/0001_initial.py @@ -0,0 +1,412 @@ +# Generated by Django 5.1.2 on 2024-10-28 06:18 + +import chatbot.models.base_models +import chatbot.models.media_models +import chatbot.models.story_models +import datetime +import django.core.validators +import django.db.models.deletion +import django_countries.fields +import django_s3_storage.storage +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BlacklistedToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.TextField(unique=True)), + ('blacklisted_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Voice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('sample_link', models.URLField(blank=True, null=True)), + ('language', models.CharField(blank=True, choices=[('en-IN', 'INDIAN ENGLISH'), ('hi-IN', 'INDIAN HINDI'), ('en-US', 'US ENGLISH'), ('kn-IN', 'INDIAN KANNADA')], default='en-IN', max_length=100, null=True)), + ('provider_code', models.CharField(blank=True, max_length=100, null=True)), + ('provider', models.CharField(blank=True, choices=[('aws', 'AWS'), ('gcp', 'GCP'), ('azure', 'Azure'), ('eleven-labs', 'Eleven Labs')], default='aws', max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='Company', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('slug', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(choices=[('ACTIVE', 'ACTIVE'), ('INACTIVE', 'INACTIVE')], max_length=20)), + ('logo_small', models.FileField(blank=True, max_length=1000, null=True, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.base_models.Company.get_file_upload_path)), + ('logo_medium', models.FileField(blank=True, max_length=1000, null=True, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.base_models.Company.get_file_upload_path)), + ('logo_large', models.FileField(blank=True, max_length=1000, null=True, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.base_models.Company.get_file_upload_path)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'indexes': [models.Index(fields=['slug'], name='chatbot_com_slug_188a57_idx')], + }, + ), + migrations.CreateModel( + name='CompanyBot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('context', models.TextField()), + ('bot_temperature', models.FloatField(default=0)), + ('top_k', models.IntegerField(default=2, validators=[django.core.validators.MinValueValidator(1)])), + ('llm_model', models.CharField(choices=[('gpt-3.5-turbo', 'GPT3.5'), ('gpt-3.5-turbo-16k', 'GPT3.5-16k'), ('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('gpt-3.5-turbo-0125', 'GPT3_5_TURBO_0125'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-3.5-turbo', max_length=100)), + ('filter_score', models.FloatField(default=0.8)), + ('image', models.FileField(blank=True, null=True, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='root-media-uploads'), upload_to=chatbot.models.base_models.CompanyBot.get_file_upload_path)), + ('end_context', models.TextField(blank=True, null=True)), + ('introductory_message', models.CharField(blank=True, max_length=1000, null=True)), + ('abrupt_introductory_message', models.CharField(blank=True, max_length=1000, null=True)), + ('tag_context', models.TextField(blank=True, null=True)), + ('route', models.CharField(default='/', max_length=100)), + ('retell_agent_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Voicebot call id')), + ('agent_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Voicebot call provider')), + ('twilio_queue', models.CharField(blank=True, max_length=255, null=True, verbose_name='Call schedule key')), + ('bot_type', models.CharField(choices=[('SIMPLE', 'SIMPLE'), ('STATE_MACHINE', 'STATE_MACHINE'), ('DATABASE_SIMPLE', 'DATABASE_SIMPLE'), ('INTERVIEW_STATE_MACHINE', 'INTERVIEW_STATE_MACHINE')], default='SIMPLE', max_length=30)), + ('llm_key', models.CharField(blank=True, max_length=255, null=True)), + ('dynamic_context', models.TextField(blank=True, null=True)), + ('dynamic_context_type', models.CharField(blank=True, choices=[('SQL_QUERY', 'SQL_QUERY'), ('PYTHON_SCRIPT', 'PYTHON_SCRIPT')], max_length=20, null=True)), + ('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)), + ('pre_context', models.TextField(blank=True, null=True)), + ('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chatbot.company')), + ('voice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.voice')), + ], + ), + migrations.CreateModel( + name='CompanyStateMachine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('step', models.IntegerField()), + ('type', models.CharField(choices=[('MANDATORY', 'MANDATORY'), ('OPTIONAL', 'OPTIONAL')], default='MANDATORY', max_length=10)), + ('bot_question', models.TextField(blank=True, null=True)), + ('completion_criteria', models.TextField(blank=True, null=True)), + ('context', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chatbot.companybot')), + ], + ), + migrations.CreateModel( + name='HistoricalProfile', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(blank=True, max_length=100, null=True)), + ('email', models.EmailField(max_length=100)), + ('phone', models.CharField(blank=True, max_length=20, null=True)), + ('alternate_phone', models.CharField(blank=True, max_length=20, null=True)), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('status', models.CharField(choices=[('ACTIVE', 'ACTIVE'), ('INACTIVE', 'INACTIVE')], default='ACTIVE', max_length=20)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('password', models.CharField(blank=True, max_length=1000, null=True)), + ('profile_type', models.CharField(choices=[('USER', 'USER'), ('MODERATOR', 'MODERATOR'), ('PROSPECT', 'PROSPECT')], default='USER', max_length=20)), + ('profile_code', models.CharField(blank=True, max_length=100, null=True)), + ('location', models.CharField(blank=True, max_length=1000, null=True)), + ('caste', models.CharField(blank=True, max_length=1000, null=True)), + ('gender', models.CharField(blank=True, choices=[('Male', 'Male'), ('Female', 'Female')], max_length=1000, null=True)), + ('designation', models.CharField(blank=True, max_length=200, null=True)), + ('org_associated', models.CharField(blank=True, max_length=1000, null=True)), + ('product_interested', models.CharField(blank=True, max_length=1000, null=True)), + ('company_spoc', models.CharField(blank=True, max_length=1000, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('source', models.CharField(blank=True, max_length=1000, null=True)), + ('preferred_route', models.CharField(blank=True, max_length=1000, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.company')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical profile', + 'verbose_name_plural': 'historical profiles', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(blank=True, max_length=100, null=True)), + ('email', models.EmailField(max_length=100)), + ('phone', models.CharField(blank=True, max_length=20, null=True)), + ('alternate_phone', models.CharField(blank=True, max_length=20, null=True)), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('status', models.CharField(choices=[('ACTIVE', 'ACTIVE'), ('INACTIVE', 'INACTIVE')], default='ACTIVE', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('password', models.CharField(blank=True, max_length=1000, null=True)), + ('profile_type', models.CharField(choices=[('USER', 'USER'), ('MODERATOR', 'MODERATOR'), ('PROSPECT', 'PROSPECT')], default='USER', max_length=20)), + ('profile_code', models.CharField(blank=True, max_length=100, null=True)), + ('location', models.CharField(blank=True, max_length=1000, null=True)), + ('caste', models.CharField(blank=True, max_length=1000, null=True)), + ('gender', models.CharField(blank=True, choices=[('Male', 'Male'), ('Female', 'Female')], max_length=1000, null=True)), + ('designation', models.CharField(blank=True, max_length=200, null=True)), + ('org_associated', models.CharField(blank=True, max_length=1000, null=True)), + ('product_interested', models.CharField(blank=True, max_length=1000, null=True)), + ('company_spoc', models.CharField(blank=True, max_length=1000, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('source', models.CharField(blank=True, max_length=1000, null=True)), + ('preferred_route', models.CharField(blank=True, max_length=1000, null=True)), + ('company', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='chatbot.company')), + ], + ), + migrations.CreateModel( + name='CompanyChat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField()), + ('translated_message', models.TextField(blank=True, null=True)), + ('chunks', models.TextField(null=True)), + ('session', models.CharField(max_length=255)), + ('created_at', models.DateTimeField()), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('IN_PROGRESS', 'IN_PROGRESS'), ('COMPLETED', 'COMPLETED')], max_length=20, null=True)), + ('feedback', models.CharField(blank=True, choices=[('POSITIVE', 'POSITIVE'), ('NEGATIVE', 'NEGATIVE')], max_length=20, null=True)), + ('source', models.CharField(choices=[('WEB', 'WEB'), ('PHONE', 'PHONE'), ('WHATSAPP', 'WHATSAPP')], default='WEB', max_length=20)), + ('source_msg_id', models.CharField(blank=True, max_length=256, null=True)), + ('whatsapp_message_id', models.CharField(blank=True, max_length=255, null=True)), + ('message_type', models.CharField(blank=True, max_length=20, null=True)), + ('stage', models.CharField(blank=True, choices=[('Welcome_Strand', 'WELCOME_STRAND'), ('Achievement_Orientation', 'ACHIEVEMENT_ORIENTATION'), ('Courage_Strand', 'COURAGE_STRAND'), ('Continuous_Strand', 'CONTINUOUS_STRAND'), ('Critical_Thinking_Strand', 'CRITICAL_THINKING_STRAND'), ('Purpose_Strand', 'PURPOSE_STRAND'), ('Thank_You_Strand', 'THANK_YOU_STRAND'), ('Other', 'OTHER')], max_length=500, null=True)), + ('receiver', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receiver', to='chatbot.profile')), + ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sender', to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='ChatSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session', models.CharField(max_length=255, unique=True)), + ('title', models.CharField(blank=True, max_length=255, null=True)), + ('summary', models.TextField(blank=True, null=True)), + ('sqs_message_id', models.CharField(blank=True, max_length=255, null=True)), + ('retell_call_id', models.CharField(blank=True, max_length=255, null=True)), + ('twilio_call_id', models.CharField(blank=True, max_length=255, null=True)), + ('call_recording_url', models.URLField(blank=True, null=True)), + ('public_log_url', models.URLField(blank=True, null=True)), + ('current_step', models.IntegerField(blank=True, null=True)), + ('next_session', models.CharField(blank=True, max_length=255, null=True)), + ('twilio_call_status', models.CharField(blank=True, max_length=255, null=True)), + ('retell_disconnection_reason', models.CharField(blank=True, max_length=255, null=True)), + ('retell_conversation_eval', models.JSONField(blank=True, null=True)), + ('session_context', models.JSONField(blank=True, null=True)), + ('session_status', models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('IN_PROGRESS', 'IN_PROGRESS'), ('COMPLETED', 'COMPLETED')], max_length=20, null=True)), + ('call_duration', models.TimeField(default=datetime.time(0, 0))), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_bot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.companybot')), + ('profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='ProfileAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address_line_1', models.CharField(blank=True, max_length=1000, null=True)), + ('address_line_2', models.CharField(blank=True, max_length=1000, null=True)), + ('block', models.CharField(blank=True, max_length=1000, null=True)), + ('city', models.CharField(blank=True, max_length=1000, null=True)), + ('district', models.CharField(blank=True, max_length=1000, null=True)), + ('state', models.CharField(blank=True, max_length=1000, null=True)), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('pincode', models.CharField(blank=True, max_length=10, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_address', to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='ProfileMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(max_length=1000, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.media_models.ProfileMedia.get_file_upload_path)), + ('base64_str', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='Story', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=1000)), + ('content', models.TextField(blank=True, null=True)), + ('tweet', models.TextField(blank=True, null=True)), + ('session', models.CharField(max_length=255, unique=True)), + ('objective', models.TextField(blank=True, null=True)), + ('action_steps', models.TextField(blank=True, null=True)), + ('impact', models.TextField(blank=True, null=True)), + ('micro_improvement', models.TextField(blank=True, null=True)), + ('location', models.CharField(blank=True, max_length=1000, null=True)), + ('formatted_content', models.TextField(blank=True, null=True)), + ('language', models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada')], default='en', max_length=1000)), + ('source', models.CharField(choices=[('AI_GENERATED', 'AI_GENERATED'), ('USER_GENERATED', 'USER_GENERATED'), ('THIRD_PARTY', 'THIRD_PARTY')], default='AI_GENERATED', max_length=1000)), + ('story_code', models.CharField(blank=True, max_length=100, null=True)), + ('stage', models.CharField(choices=[('PENDING', 'PENDING'), ('COMPLETED', 'COMPLETED')], default='PENDING', max_length=100)), + ('summary', models.TextField(blank=True, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('client_created_at', models.DateTimeField(blank=True, null=True)), + ('client_updated_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='StoryMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=1000)), + ('file', models.FileField(max_length=1000, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.story_models.StoryMedia.get_file_upload_path)), + ('include_in_story', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('base64_str', models.TextField(blank=True, null=True)), + ('media_type', models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG')], max_length=100, null=True)), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_media', to='chatbot.story')), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=1000, unique=True, validators=[django.core.validators.MinLengthValidator(limit_value=3)])), + ('status', models.CharField(choices=[('Approved', 'Approved'), ('Pending', 'Pending')], default='Pending', max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.company')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.profile')), + ], + ), + migrations.CreateModel( + name='StoryTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_primary', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.profile')), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chatbot.story')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='chatbot.tag')), + ], + ), + migrations.CreateModel( + name='HistoricalCompanyBot', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('context', models.TextField()), + ('bot_temperature', models.FloatField(default=0)), + ('top_k', models.IntegerField(default=2, validators=[django.core.validators.MinValueValidator(1)])), + ('llm_model', models.CharField(choices=[('gpt-3.5-turbo', 'GPT3.5'), ('gpt-3.5-turbo-16k', 'GPT3.5-16k'), ('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('gpt-3.5-turbo-0125', 'GPT3_5_TURBO_0125'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-3.5-turbo', max_length=100)), + ('filter_score', models.FloatField(default=0.8)), + ('image', models.TextField(blank=True, max_length=100, null=True)), + ('end_context', models.TextField(blank=True, null=True)), + ('introductory_message', models.CharField(blank=True, max_length=1000, null=True)), + ('abrupt_introductory_message', models.CharField(blank=True, max_length=1000, null=True)), + ('tag_context', models.TextField(blank=True, null=True)), + ('route', models.CharField(default='/', max_length=100)), + ('retell_agent_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Voicebot call id')), + ('agent_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Voicebot call provider')), + ('twilio_queue', models.CharField(blank=True, max_length=255, null=True, verbose_name='Call schedule key')), + ('bot_type', models.CharField(choices=[('SIMPLE', 'SIMPLE'), ('STATE_MACHINE', 'STATE_MACHINE'), ('DATABASE_SIMPLE', 'DATABASE_SIMPLE'), ('INTERVIEW_STATE_MACHINE', 'INTERVIEW_STATE_MACHINE')], default='SIMPLE', max_length=30)), + ('llm_key', models.CharField(blank=True, max_length=255, null=True)), + ('dynamic_context', models.TextField(blank=True, null=True)), + ('dynamic_context_type', models.CharField(blank=True, choices=[('SQL_QUERY', 'SQL_QUERY'), ('PYTHON_SCRIPT', 'PYTHON_SCRIPT')], max_length=20, null=True)), + ('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)), + ('pre_context', models.TextField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.company')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('voice', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.voice')), + ], + options={ + 'verbose_name': 'historical company bot', + 'verbose_name_plural': 'historical company bots', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['email'], name='chatbot_pro_email_540b78_idx'), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['phone'], name='chatbot_pro_phone_0d562f_idx'), + ), + migrations.AlterUniqueTogether( + name='profile', + unique_together={('email', 'company')}, + ), + migrations.AddIndex( + model_name='companychat', + index=models.Index(fields=['session'], name='chatbot_com_session_aea093_idx'), + ), + migrations.AddIndex( + model_name='companychat', + index=models.Index(fields=['created_at'], name='chatbot_com_created_9e7c24_idx'), + ), + migrations.AddIndex( + model_name='companychat', + index=models.Index(fields=['sender'], name='chatbot_com_sender__595370_idx'), + ), + migrations.AddIndex( + model_name='companychat', + index=models.Index(fields=['receiver'], name='chatbot_com_receive_8110fa_idx'), + ), + migrations.AddIndex( + model_name='story', + index=models.Index(fields=['title'], name='chatbot_sto_title_675491_idx'), + ), + migrations.AddIndex( + model_name='story', + index=models.Index(fields=['session'], name='chatbot_sto_session_b91e29_idx'), + ), + migrations.AddIndex( + model_name='story', + index=models.Index(fields=['author'], name='chatbot_sto_author__1a0e07_idx'), + ), + migrations.AlterUniqueTogether( + name='storytag', + unique_together={('story', 'tag')}, + ), + migrations.AddIndex( + model_name='companybot', + index=models.Index(fields=['company'], name='chatbot_com_company_9f12b6_idx'), + ), + ] diff --git a/chatbot/migrations/0002_remove_company_logo_large_remove_company_logo_medium_and_more.py b/chatbot/migrations/0002_remove_company_logo_large_remove_company_logo_medium_and_more.py new file mode 100644 index 0000000..db80672 --- /dev/null +++ b/chatbot/migrations/0002_remove_company_logo_large_remove_company_logo_medium_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.2 on 2024-10-29 06:00 + +import chatbot.models.base_models +import chatbot.models.media_models +import chatbot.models.story_models +import django.core.files.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='company', + name='logo_large', + ), + migrations.RemoveField( + model_name='company', + name='logo_medium', + ), + migrations.RemoveField( + model_name='company', + name='logo_small', + ), + migrations.AlterField( + model_name='companybot', + name='image', + field=models.FileField(blank=True, null=True, storage=django.core.files.storage.FileSystemStorage(location='media/uploads'), upload_to=chatbot.models.base_models.CompanyBot.get_file_upload_path), + ), + migrations.AlterField( + model_name='profilemedia', + name='file', + field=models.FileField(max_length=1000, storage=django.core.files.storage.FileSystemStorage(location='media/uploads'), upload_to=chatbot.models.media_models.ProfileMedia.get_file_upload_path), + ), + migrations.AlterField( + model_name='storymedia', + name='file', + field=models.FileField(max_length=1000, storage=django.core.files.storage.FileSystemStorage(location='media/uploads'), upload_to=chatbot.models.story_models.StoryMedia.get_file_upload_path), + ), + ] diff --git a/chatbot/migrations/0003_remove_companybot_image_and_more.py b/chatbot/migrations/0003_remove_companybot_image_and_more.py new file mode 100644 index 0000000..f3ec04e --- /dev/null +++ b/chatbot/migrations/0003_remove_companybot_image_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.2 on 2024-10-29 10:23 + +import chatbot.models.media_models +import chatbot.models.story_models +import django_s3_storage.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0002_remove_company_logo_large_remove_company_logo_medium_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='companybot', + name='image', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='image', + ), + migrations.AlterField( + model_name='profilemedia', + name='file', + field=models.FileField(max_length=1000, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='static-media.gritworks.ai'), upload_to=chatbot.models.media_models.ProfileMedia.get_file_upload_path), + ), + migrations.AlterField( + model_name='storymedia', + name='file', + field=models.FileField(max_length=1000, upload_to=chatbot.models.story_models.StoryMedia.get_file_upload_path), + ), + ] diff --git a/chatbot/migrations/0004_alter_profilemedia_file.py b/chatbot/migrations/0004_alter_profilemedia_file.py new file mode 100644 index 0000000..1e952b3 --- /dev/null +++ b/chatbot/migrations/0004_alter_profilemedia_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2024-11-15 04:46 + +import chatbot.models.media_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0003_remove_companybot_image_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='profilemedia', + name='file', + field=models.FileField(max_length=1000, upload_to=chatbot.models.media_models.ProfileMedia.get_file_upload_path), + ), + ] diff --git a/chatbot/migrations/0005_alter_historicalprofile_country_and_more.py b/chatbot/migrations/0005_alter_historicalprofile_country_and_more.py new file mode 100644 index 0000000..2339d81 --- /dev/null +++ b/chatbot/migrations/0005_alter_historicalprofile_country_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2024-11-21 03:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0004_alter_profilemedia_file'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprofile', + name='country', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='profile', + name='country', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='profileaddress', + name='country', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/chatbot/migrations/0006_alter_chatsession_session_status_and_more.py b/chatbot/migrations/0006_alter_chatsession_session_status_and_more.py new file mode 100644 index 0000000..e58e8a4 --- /dev/null +++ b/chatbot/migrations/0006_alter_chatsession_session_status_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-12-06 05:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0005_alter_historicalprofile_country_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='chatsession', + name='session_status', + field=models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('IN_PROGRESS', 'IN_PROGRESS'), ('COMPLETED', 'COMPLETED'), ('PAUSED', 'PAUSED'), ('RESUME', 'RESUME')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='companychat', + name='status', + field=models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('IN_PROGRESS', 'IN_PROGRESS'), ('COMPLETED', 'COMPLETED'), ('PAUSED', 'PAUSED'), ('RESUME', 'RESUME')], max_length=20, null=True), + ), + ] diff --git a/chatbot/migrations/0006_remove_chatsession_call_duration_and_more.py b/chatbot/migrations/0006_remove_chatsession_call_duration_and_more.py new file mode 100644 index 0000000..ce0f395 --- /dev/null +++ b/chatbot/migrations/0006_remove_chatsession_call_duration_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.2 on 2024-12-06 05:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0005_alter_historicalprofile_country_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='chatsession', + name='call_duration', + ), + migrations.RemoveField( + model_name='chatsession', + name='call_recording_url', + ), + migrations.RemoveField( + model_name='chatsession', + name='public_log_url', + ), + migrations.RemoveField( + model_name='chatsession', + name='retell_call_id', + ), + migrations.RemoveField( + model_name='chatsession', + name='retell_conversation_eval', + ), + migrations.RemoveField( + model_name='chatsession', + name='retell_disconnection_reason', + ), + migrations.RemoveField( + model_name='chatsession', + name='twilio_call_id', + ), + migrations.RemoveField( + model_name='chatsession', + name='twilio_call_status', + ), + ] diff --git a/chatbot/migrations/0007_remove_chatsession_next_session_and_more.py b/chatbot/migrations/0007_remove_chatsession_next_session_and_more.py new file mode 100644 index 0000000..aa56677 --- /dev/null +++ b/chatbot/migrations/0007_remove_chatsession_next_session_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2024-12-06 05:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0006_remove_chatsession_call_duration_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='chatsession', + name='next_session', + ), + migrations.RemoveField( + model_name='chatsession', + name='sqs_message_id', + ), + ] diff --git a/chatbot/migrations/0008_merge_20241206_1208.py b/chatbot/migrations/0008_merge_20241206_1208.py new file mode 100644 index 0000000..27d0918 --- /dev/null +++ b/chatbot/migrations/0008_merge_20241206_1208.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2024-12-06 06:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0006_alter_chatsession_session_status_and_more'), + ('chatbot', '0007_remove_chatsession_next_session_and_more'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0009_media_keyvalue_mediavector.py b/chatbot/migrations/0009_media_keyvalue_mediavector.py new file mode 100644 index 0000000..4d8e7f5 --- /dev/null +++ b/chatbot/migrations/0009_media_keyvalue_mediavector.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.2 on 2024-12-12 10:07 + +import chatbot.models.media_models +import django.db.models.deletion +import django_s3_storage.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0008_merge_20241206_1208'), + ] + + operations = [ + migrations.CreateModel( + name='Media', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=1000)), + ('url', models.URLField(blank=True, max_length=1000, null=True)), + ('media_type', models.CharField(choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG')], default='text/plain', max_length=100)), + ('file', models.FileField(max_length=1000, storage=django_s3_storage.storage.S3Storage(aws_s3_bucket_name='mohini-static.shikshalokam.org'), upload_to=chatbot.models.media_models.Media.get_file_upload_path)), + ('description', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_bot', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='chatbot.companybot')), + ], + ), + migrations.CreateModel( + name='KeyValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=1000)), + ('value', models.CharField(max_length=10000)), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='key_values', to='chatbot.media')), + ], + ), + migrations.CreateModel( + name='MediaVector', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vector_id', models.CharField(blank=True, max_length=1000, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_vector', to='chatbot.media')), + ], + ), + ] diff --git a/chatbot/migrations/0010_alter_media_media_type_alter_storymedia_media_type_and_more.py b/chatbot/migrations/0010_alter_media_media_type_alter_storymedia_media_type_and_more.py new file mode 100644 index 0000000..5ac20df --- /dev/null +++ b/chatbot/migrations/0010_alter_media_media_type_alter_storymedia_media_type_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.2 on 2025-02-07 08:19 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0009_media_keyvalue_mediavector'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG')], default='text/plain', max_length=100), + ), + migrations.AlterField( + model_name='storymedia', + name='media_type', + field=models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG')], max_length=100, null=True), + ), + migrations.CreateModel( + name='HistoricalBotVernacular', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('language', models.CharField(help_text='Language code, Example for English use en.', max_length=250)), + ('introductory_message', models.TextField(blank=True, help_text='Provide an introductory message that the bot will present when the conversation starts.', null=True)), + ('name', models.CharField(blank=True, help_text='Enter the name of the bot.', max_length=100, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company_bot', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical bot vernacular', + 'verbose_name_plural': 'historical bot vernaculars', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='BotVernacular', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language', models.CharField(help_text='Language code, Example for English use en.', max_length=250)), + ('introductory_message', models.TextField(blank=True, help_text='Provide an introductory message that the bot will present when the conversation starts.', null=True)), + ('name', models.CharField(blank=True, help_text='Enter the name of the bot.', max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_vernacular', to='chatbot.companybot')), + ], + options={ + 'db_table': 'shikshalokam"."bot_vernacular', + 'indexes': [models.Index(fields=['language'], name='bot_vernacu_languag_52e6c8_idx'), models.Index(fields=['created_at'], name='bot_vernacu_created_d1c395_idx'), models.Index(fields=['company_bot'], name='bot_vernacu_company_483975_idx')], + # 'unique_together': {('company_bot', 'language')}, + }, + ), + ] diff --git a/chatbot/migrations/0011_remove_companybot_abrupt_introductory_message_and_more.py b/chatbot/migrations/0011_remove_companybot_abrupt_introductory_message_and_more.py new file mode 100644 index 0000000..e5f0a95 --- /dev/null +++ b/chatbot/migrations/0011_remove_companybot_abrupt_introductory_message_and_more.py @@ -0,0 +1,109 @@ +# Generated by Django 5.1.2 on 2025-02-07 10:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0010_alter_media_media_type_alter_storymedia_media_type_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='companybot', + name='abrupt_introductory_message', + ), + migrations.RemoveField( + model_name='companybot', + name='agent_provider', + ), + migrations.RemoveField( + model_name='companybot', + name='retell_agent_id', + ), + migrations.RemoveField( + model_name='companybot', + name='twilio_queue', + ), + migrations.RemoveField( + model_name='companybot', + name='voice', + ), + migrations.RemoveField( + model_name='companybot', + name='whatsapp_number', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='abrupt_introductory_message', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='agent_provider', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='retell_agent_id', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='twilio_queue', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='voice', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='whatsapp_number', + ), + migrations.RemoveField( + model_name='voice', + name='language', + ), + migrations.RemoveField( + model_name='voice', + name='name', + ), + migrations.RemoveField( + model_name='voice', + name='provider_code', + ), + migrations.RemoveField( + model_name='voice', + name='sample_link', + ), + migrations.AddField( + model_name='voice', + name='company_bot', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='chatbot.companybot'), + ), + migrations.AddField( + model_name='voice', + name='type', + field=models.CharField(blank=True, choices=[('SpeechToText', 'Speech To Text'), ('TextToText', 'Text To Text'), ('TextToSpeech', 'Text To Speech')], max_length=300, null=True), + ), + migrations.AlterField( + model_name='voice', + name='provider', + field=models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('AI4Bharat', 'AI4Bharat')], default='AI4Bharat', max_length=300, null=True), + ), + migrations.AddIndex( + model_name='voice', + index=models.Index(fields=['company_bot'], name='chatbot_voi_company_9292a4_idx'), + ), + migrations.AddIndex( + model_name='voice', + index=models.Index(fields=['created_at'], name='chatbot_voi_created_d21d8e_idx'), + ), + migrations.AddIndex( + model_name='voice', + index=models.Index(fields=['type'], name='chatbot_voi_type_98349d_idx'), + ), + migrations.AddIndex( + model_name='voice', + index=models.Index(fields=['provider'], name='chatbot_voi_provide_b4b230_idx'), + ), + ] diff --git a/chatbot/migrations/0012_story_blurb.py b/chatbot/migrations/0012_story_blurb.py new file mode 100644 index 0000000..17b4b3e --- /dev/null +++ b/chatbot/migrations/0012_story_blurb.py @@ -0,0 +1,13 @@ +# Generated by Django 5.1.2 on 2025-02-08 03:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0011_remove_companybot_abrupt_introductory_message_and_more'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0013_companybot_max_token_historicalcompanybot_max_token.py b/chatbot/migrations/0013_companybot_max_token_historicalcompanybot_max_token.py new file mode 100644 index 0000000..1ed061b --- /dev/null +++ b/chatbot/migrations/0013_companybot_max_token_historicalcompanybot_max_token.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-02-08 04:41 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0011_remove_companybot_abrupt_introductory_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='max_token', + field=models.IntegerField(default=2048, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='max_token', + field=models.IntegerField(default=2048, validators=[django.core.validators.MinValueValidator(1)]), + ), + ] diff --git a/chatbot/migrations/0014_alter_companybot_llm_model_and_more.py b/chatbot/migrations/0014_alter_companybot_llm_model_and_more.py new file mode 100644 index 0000000..54fa7bc --- /dev/null +++ b/chatbot/migrations/0014_alter_companybot_llm_model_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-02-08 04:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0013_companybot_max_token_historicalcompanybot_max_token'), + ] + + operations = [ + migrations.AlterField( + model_name='companybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + ] diff --git a/chatbot/migrations/0015_companybot_tool_context_and_more.py b/chatbot/migrations/0015_companybot_tool_context_and_more.py new file mode 100644 index 0000000..a5d65b9 --- /dev/null +++ b/chatbot/migrations/0015_companybot_tool_context_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-02-08 07:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0014_alter_companybot_llm_model_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='tool_context', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='tool_context', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='companybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + ] diff --git a/chatbot/migrations/0016_companybot_provider_companybot_provider_keys_and_more.py b/chatbot/migrations/0016_companybot_provider_companybot_provider_keys_and_more.py new file mode 100644 index 0000000..38e9e0c --- /dev/null +++ b/chatbot/migrations/0016_companybot_provider_companybot_provider_keys_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.2 on 2025-02-13 09:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0015_companybot_tool_context_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='provider', + field=models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', + 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='openai', max_length=100), + ), + migrations.AddField( + model_name='companybot', + name='provider_keys', + field=models.TextField(blank=True, default='', max_length=1000), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='provider', + field=models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', + 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='openai', max_length=100), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='provider_keys', + field=models.TextField(blank=True, default='', max_length=1000), + ), + ] diff --git a/chatbot/migrations/0016_story_validation_logs.py b/chatbot/migrations/0016_story_validation_logs.py new file mode 100644 index 0000000..7674d5d --- /dev/null +++ b/chatbot/migrations/0016_story_validation_logs.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-13 07:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0015_companybot_tool_context_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='story', + name='validation_logs', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0017_merge_20250214_1505.py b/chatbot/migrations/0017_merge_20250214_1505.py new file mode 100644 index 0000000..3a73434 --- /dev/null +++ b/chatbot/migrations/0017_merge_20250214_1505.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-02-14 09:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0016_companybot_provider_companybot_provider_keys_and_more'), + ('chatbot', '0016_story_validation_logs'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0018_merge_0012_story_blurb_0017_merge_20250214_1505.py b/chatbot/migrations/0018_merge_0012_story_blurb_0017_merge_20250214_1505.py new file mode 100644 index 0000000..2a1d543 --- /dev/null +++ b/chatbot/migrations/0018_merge_0012_story_blurb_0017_merge_20250214_1505.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-02-19 13:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0012_story_blurb'), + ('chatbot', '0017_merge_20250214_1505'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0019_story_blurb.py b/chatbot/migrations/0019_story_blurb.py new file mode 100644 index 0000000..de107f0 --- /dev/null +++ b/chatbot/migrations/0019_story_blurb.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-19 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0018_merge_0012_story_blurb_0017_merge_20250214_1505'), + ] + + operations = [ + migrations.AddField( + model_name='story', + name='blurb', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0020_remove_companybot_max_token_and_more.py b/chatbot/migrations/0020_remove_companybot_max_token_and_more.py new file mode 100644 index 0000000..15890df --- /dev/null +++ b/chatbot/migrations/0020_remove_companybot_max_token_and_more.py @@ -0,0 +1,300 @@ +# Generated by Django 5.1.2 on 2025-02-20 10:55 + +import chatbot.models.media_models +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0019_story_blurb'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='companybot', + name='max_token', + ), + migrations.RemoveField( + model_name='companybot', + name='provider', + ), + migrations.RemoveField( + model_name='companybot', + name='provider_keys', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='max_token', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='provider', + ), + migrations.RemoveField( + model_name='historicalcompanybot', + name='provider_keys', + ), + migrations.AddField( + model_name='chatsession', + name='project_id', + field=models.CharField(blank=True, max_length=400, null=True), + ), + migrations.AddField( + model_name='chatsession', + name='user_id', + field=models.CharField(blank=True, max_length=400, null=True), + ), + migrations.AddField( + model_name='historicalprofile', + name='userid', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='media', + name='priority', + field=models.CharField(choices=[('P1', 'P1'), ('P2', 'P2'), ('P3', 'P3')], default='P1', max_length=50), + ), + migrations.AddField( + model_name='profile', + name='userid', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='storymedia', + name='source_path', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='voice', + name='language', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='voice', + name='name', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='voice', + name='provider_code', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='voice', + name='sample_link', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='companybot', + name='bot_temperature', + field=models.FloatField(default=0, help_text='Set the temperature for controlling response randomness (0-1). Lower values produce more deterministic responses.'), + ), + migrations.AlterField( + model_name='companybot', + name='company', + field=models.ForeignKey(help_text='Select the company this bot belongs to.', on_delete=django.db.models.deletion.CASCADE, to='chatbot.company'), + ), + migrations.AlterField( + model_name='companybot', + name='context', + field=models.TextField(help_text="Provide the bot's main prompt or description of its purpose."), + ), + migrations.AlterField( + model_name='companybot', + name='dynamic_context', + field=models.TextField(blank=True, help_text="Provide dynamic context that can be adjusted during the bot's interactions, such as personalized data.", null=True), + ), + migrations.AlterField( + model_name='companybot', + name='end_context', + field=models.TextField(blank=True, help_text='Provide additional prompt or context to append at the end of the main prompt to guide the conversation', null=True), + ), + migrations.AlterField( + model_name='companybot', + name='filter_score', + field=models.FloatField(default=0.8, help_text='Set the filter score for bot response selection (0-1). Responses below this score will be filtered out.'), + ), + migrations.AlterField( + model_name='companybot', + name='introductory_message', + field=models.CharField(blank=True, help_text='Provide an introductory message that the bot will present when the conversation starts.', max_length=1000, null=True), + ), + migrations.AlterField( + model_name='companybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='companybot', + name='name', + field=models.CharField(help_text='Enter the name of the bot.', max_length=100), + ), + migrations.AlterField( + model_name='companybot', + name='pre_context', + field=models.TextField(blank=True, help_text='Provide pre-context that will be set before the main prompt to shape the conversation.', null=True), + ), + migrations.AlterField( + model_name='companybot', + name='route', + field=models.CharField(default='/', help_text='Specify the route or API endpoint for interacting with the bot.', max_length=100), + ), + migrations.AlterField( + model_name='companybot', + name='tag_context', + field=models.TextField(blank=True, help_text='Provide any information or context related to variables (like Python-bound variables) that will be inserted into the prompt.', null=True), + ), + migrations.AlterField( + model_name='companybot', + name='top_k', + field=models.IntegerField(default=2, help_text="Set the top-k value for the bot's response selection. This defines how many top options to consider for each response.", validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='companychat', + name='source', + field=models.CharField(choices=[('WEB', 'WEB'), ('PHONE', 'PHONE')], default='WEB', max_length=20), + ), + migrations.AlterField( + model_name='companystatemachine', + name='bot_question', + field=models.TextField(blank=True, help_text='Provide the first question that the bot will ask when the state is triggered.', null=True), + ), + migrations.AlterField( + model_name='companystatemachine', + name='completion_criteria', + field=models.TextField(blank=True, help_text='Define the criteria required to move from this state to the next state.', null=True), + ), + migrations.AlterField( + model_name='companystatemachine', + name='context', + field=models.TextField(blank=True, help_text='Provide the main prompt or description of the state, explaining its purpose.', null=True), + ), + migrations.AlterField( + model_name='companystatemachine', + name='name', + field=models.CharField(help_text='Enter the name of the state.', max_length=100), + ), + migrations.AlterField( + model_name='companystatemachine', + name='step', + field=models.IntegerField(help_text='Integer representing the order in which state function calling happens. Lower values are called first.'), + ), + migrations.AlterField( + model_name='companystatemachine', + name='type', + field=models.CharField(choices=[('MANDATORY', 'MANDATORY'), ('OPTIONAL', 'OPTIONAL')], default='MANDATORY', help_text='Specify whether the state is mandatory or optional.', max_length=10), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='bot_temperature', + field=models.FloatField(default=0, help_text='Set the temperature for controlling response randomness (0-1). Lower values produce more deterministic responses.'), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='company', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Select the company this bot belongs to.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.company'), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='context', + field=models.TextField(help_text="Provide the bot's main prompt or description of its purpose."), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='dynamic_context', + field=models.TextField(blank=True, help_text="Provide dynamic context that can be adjusted during the bot's interactions, such as personalized data.", null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='end_context', + field=models.TextField(blank=True, help_text='Provide additional prompt or context to append at the end of the main prompt to guide the conversation', null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='filter_score', + field=models.FloatField(default=0.8, help_text='Set the filter score for bot response selection (0-1). Responses below this score will be filtered out.'), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='introductory_message', + field=models.CharField(blank=True, help_text='Provide an introductory message that the bot will present when the conversation starts.', max_length=1000, null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='name', + field=models.CharField(help_text='Enter the name of the bot.', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='pre_context', + field=models.TextField(blank=True, help_text='Provide pre-context that will be set before the main prompt to shape the conversation.', null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='route', + field=models.CharField(default='/', help_text='Specify the route or API endpoint for interacting with the bot.', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='tag_context', + field=models.TextField(blank=True, help_text='Provide any information or context related to variables (like Python-bound variables) that will be inserted into the prompt.', null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='top_k', + field=models.IntegerField(default=2, help_text="Set the top-k value for the bot's response selection. This defines how many top options to consider for each response.", validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='historicalprofile', + name='designation', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='media', + name='file', + field=models.FileField(max_length=1000, upload_to=chatbot.models.media_models.Media.get_file_upload_path), + ), + migrations.AlterField( + model_name='profile', + name='designation', + field=models.TextField(blank=True, null=True), + ), + migrations.CreateModel( + name='HistoricalCompanyStateMachine', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(help_text='Enter the name of the state.', max_length=100)), + ('step', models.IntegerField(help_text='Integer representing the order in which state function calling happens. Lower values are called first.')), + ('type', models.CharField(choices=[('MANDATORY', 'MANDATORY'), ('OPTIONAL', 'OPTIONAL')], default='MANDATORY', help_text='Specify whether the state is mandatory or optional.', max_length=10)), + ('bot_question', models.TextField(blank=True, help_text='Provide the first question that the bot will ask when the state is triggered.', null=True)), + ('completion_criteria', models.TextField(blank=True, help_text='Define the criteria required to move from this state to the next state.', null=True)), + ('context', models.TextField(blank=True, help_text='Provide the main prompt or description of the state, explaining its purpose.', null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company_bot', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical company state machine', + 'verbose_name_plural': 'historical company state machines', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/chatbot/migrations/0021_companybot_max_token_historicalcompanybot_max_token.py b/chatbot/migrations/0021_companybot_max_token_historicalcompanybot_max_token.py new file mode 100644 index 0000000..0bcf921 --- /dev/null +++ b/chatbot/migrations/0021_companybot_max_token_historicalcompanybot_max_token.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-02-20 11:06 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0020_remove_companybot_max_token_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='max_token', + field=models.IntegerField(default=2048, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='max_token', + field=models.IntegerField(default=2048, validators=[django.core.validators.MinValueValidator(1)]), + ), + ] diff --git a/chatbot/migrations/0022_botvernacular_alt_introductory_message_and_more.py b/chatbot/migrations/0022_botvernacular_alt_introductory_message_and_more.py new file mode 100644 index 0000000..65ac354 --- /dev/null +++ b/chatbot/migrations/0022_botvernacular_alt_introductory_message_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.2 on 2025-02-21 03:29 + +import chatbot.models.base_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0021_companybot_max_token_historicalcompanybot_max_token'), + ] + + operations = [ + migrations.AddField( + model_name='botvernacular', + name='alt_introductory_message', + field=models.TextField(blank=True, help_text='Provide an alternate introductory message that the bot will present when the conversation starts.', null=True), + ), + migrations.AddField( + model_name='botvernacular', + name='error_message', + field=models.TextField(blank=True, help_text='Provide an error message that the bot will display.', null=True), + ), + migrations.AddField( + model_name='company', + name='logo', + field=models.ImageField(blank=True, max_length=1000, null=True, upload_to=chatbot.models.base_models.Company.get_file_upload_path), + ), + migrations.AddField( + model_name='historicalbotvernacular', + name='alt_introductory_message', + field=models.TextField(blank=True, help_text='Provide an alternate introductory message that the bot will present when the conversation starts.', null=True), + ), + migrations.AddField( + model_name='historicalbotvernacular', + name='error_message', + field=models.TextField(blank=True, help_text='Provide an error message that the bot will display.', null=True), + ), + ] diff --git a/chatbot/migrations/0023_companybot_provider_companybot_provider_keys_and_more.py b/chatbot/migrations/0023_companybot_provider_companybot_provider_keys_and_more.py new file mode 100644 index 0000000..76f7edf --- /dev/null +++ b/chatbot/migrations/0023_companybot_provider_companybot_provider_keys_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.2 on 2025-02-26 07:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0022_botvernacular_alt_introductory_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='provider', + field=models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='bedrock/converse', help_text='Select the LLM provider (BEDROCK, BEDROCK_CONVERSE, or OPENAI)', max_length=100), + ), + migrations.AddField( + model_name='companybot', + name='provider_keys', + field=models.TextField(blank=True, default='', help_text='API keys or credentials for the selected LLM provider.', max_length=1000), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='provider', + field=models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='bedrock/converse', help_text='Select the LLM provider (BEDROCK, BEDROCK_CONVERSE, or OPENAI)', max_length=100), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='provider_keys', + field=models.TextField(blank=True, default='', help_text='API keys or credentials for the selected LLM provider.', max_length=1000), + ), + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP')], default='text/plain', max_length=100), + ), + migrations.AlterField( + model_name='storymedia', + name='media_type', + field=models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0024_voice_gender_voice_voice_speed_alter_voice_provider.py b/chatbot/migrations/0024_voice_gender_voice_voice_speed_alter_voice_provider.py new file mode 100644 index 0000000..839eb61 --- /dev/null +++ b/chatbot/migrations/0024_voice_gender_voice_voice_speed_alter_voice_provider.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.2 on 2025-03-04 08:00 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0023_companybot_provider_companybot_provider_keys_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='voice', + name='gender', + field=models.CharField(choices=[('Male', 'Male'), ('Female', 'Female')], default='Male', max_length=100), + ), + migrations.AddField( + model_name='voice', + name='voice_speed', + field=models.FloatField(blank=True, default=1.0, null=True, validators=[django.core.validators.MinValueValidator(0.25), django.core.validators.MaxValueValidator(4.0)]), + ), + migrations.AlterField( + model_name='voice', + name='provider', + field=models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('GOOGLE_V1', 'GOOGLE v1 STT'), ('AI4Bharat', 'AI4Bharat'), ('OPENAI_WHISPER', 'OpenAI Whisper')], default='AI4Bharat', max_length=300, null=True), + ), + ] diff --git a/chatbot/migrations/0025_chatsession_session_type.py b/chatbot/migrations/0025_chatsession_session_type.py new file mode 100644 index 0000000..df41b23 --- /dev/null +++ b/chatbot/migrations/0025_chatsession_session_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-03-18 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0024_voice_gender_voice_voice_speed_alter_voice_provider'), + ] + + operations = [ + migrations.AddField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'guidedReflection'), ('oneshot', 'oneStepReflection')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0026_historicalstoryvernacular_storyvernacular.py b/chatbot/migrations/0026_historicalstoryvernacular_storyvernacular.py new file mode 100644 index 0000000..1ba2554 --- /dev/null +++ b/chatbot/migrations/0026_historicalstoryvernacular_storyvernacular.py @@ -0,0 +1,55 @@ +# Generated by Django 5.1.2 on 2025-03-19 09:05 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0025_chatsession_session_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalStoryVernacular', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('translation_json', models.JSONField(blank=True, help_text='JSON object containing translated text in the specified language.', null=True)), + ('language', models.CharField(help_text='Language code, Example for English use en.', max_length=250)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company_bot', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical story vernacular', + 'verbose_name_plural': 'historical story vernaculars', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='StoryVernacular', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('translation_json', models.JSONField(blank=True, help_text='JSON object containing translated text in the specified language.', null=True)), + ('language', models.CharField(help_text='Language code, Example for English use en.', max_length=250)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('company_bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_vernacular', to='chatbot.companybot')), + ], + options={ + 'indexes': [models.Index(fields=['language'], name='chatbot_sto_languag_93248d_idx'), models.Index(fields=['created_at'], name='chatbot_sto_created_5678f2_idx'), models.Index(fields=['company_bot'], name='chatbot_sto_company_ac3b1a_idx')], + 'unique_together': {('company_bot', 'language')}, + }, + ), + ] diff --git a/chatbot/migrations/0027_alter_media_media_type_alter_storymedia_media_type.py b/chatbot/migrations/0027_alter_media_media_type_alter_storymedia_media_type.py new file mode 100644 index 0000000..41627b3 --- /dev/null +++ b/chatbot/migrations/0027_alter_media_media_type_alter_storymedia_media_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-03-21 05:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0026_historicalstoryvernacular_storyvernacular'), + ] + + operations = [ + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP'), ('image/heif', 'HEIF'), ('image/heic', 'HEIC')], default='text/plain', max_length=100), + ), + migrations.AlterField( + model_name='storymedia', + name='media_type', + field=models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP'), ('image/heif', 'HEIF'), ('image/heic', 'HEIC')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0028_mediatemplate.py b/chatbot/migrations/0028_mediatemplate.py new file mode 100644 index 0000000..f8fb522 --- /dev/null +++ b/chatbot/migrations/0028_mediatemplate.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2025-03-21 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0027_alter_media_media_type_alter_storymedia_media_type'), + ] + + operations = [ + migrations.CreateModel( + name='MediaTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, null=True, unique=True)), + ('template_content', models.TextField(null=True)), + ('template_type', models.CharField(choices=[('EJS', 'EJS'), ('RAW-TEXT', 'RAW-TEXT')], max_length=100, null=True)), + ('pdf_strategy', models.CharField(choices=[('HTMLPDF', 'HTMLPDF'), ('PUPPETEER', 'PUPPETEER'), ('HTMLDOCX', 'HTMLDOCX'), ('XLSX', 'XLSX')], max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/chatbot/migrations/0029_companybot_connect_timeout_companybot_read_timeout_and_more.py b/chatbot/migrations/0029_companybot_connect_timeout_companybot_read_timeout_and_more.py new file mode 100644 index 0000000..209f2f1 --- /dev/null +++ b/chatbot/migrations/0029_companybot_connect_timeout_companybot_read_timeout_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-03-26 03:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0028_mediatemplate'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='connect_timeout', + field=models.FloatField(default=5.0, help_text='Timeout in seconds for establishing a LLM connection.'), + ), + migrations.AddField( + model_name='companybot', + name='read_timeout', + field=models.FloatField(default=10.0, help_text='Timeout in seconds for reading a LLM response.'), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='connect_timeout', + field=models.FloatField(default=5.0, help_text='Timeout in seconds for establishing a LLM connection.'), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='read_timeout', + field=models.FloatField(default=10.0, help_text='Timeout in seconds for reading a LLM response.'), + ), + ] diff --git a/chatbot/migrations/0030_companychat_audio_file_and_more.py b/chatbot/migrations/0030_companychat_audio_file_and_more.py new file mode 100644 index 0000000..12ffb75 --- /dev/null +++ b/chatbot/migrations/0030_companychat_audio_file_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-03-31 14:34 + +import chatbot.models.base_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0029_companybot_connect_timeout_companybot_read_timeout_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companychat', + name='audio_file', + field=models.FileField(blank=True, max_length=1000, null=True, upload_to=chatbot.models.base_models.CompanyChat.get_file_upload_path), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'guidedReflection'), ('oneshot', 'oneStepReflection'), ('shikshalokam_chaupal', 'shikshaChaupal')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0031_alter_chatsession_session_type_and_more.py b/chatbot/migrations/0031_alter_chatsession_session_type_and_more.py new file mode 100644 index 0000000..5bc7e00 --- /dev/null +++ b/chatbot/migrations/0031_alter_chatsession_session_type_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2025-04-04 11:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0030_companychat_audio_file_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'guidedReflection'), ('oneshot', 'oneStepReflection'), ('shikshalokam_chaupal', 'shikshaChaupal'), ('reflection', 'reflection'), ('creation', 'creation')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalprofile', + name='email', + field=models.EmailField(max_length=1000), + ), + migrations.AlterField( + model_name='profile', + name='email', + field=models.EmailField(max_length=1000), + ), + ] diff --git a/chatbot/migrations/0032_alter_historicalprofile_first_name_and_more.py b/chatbot/migrations/0032_alter_historicalprofile_first_name_and_more.py new file mode 100644 index 0000000..6b695bc --- /dev/null +++ b/chatbot/migrations/0032_alter_historicalprofile_first_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-04-04 11:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0031_alter_chatsession_session_type_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprofile', + name='first_name', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='profile', + name='first_name', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0033_historicalprofile_latest_flow_used_and_more.py b/chatbot/migrations/0033_historicalprofile_latest_flow_used_and_more.py new file mode 100644 index 0000000..7d7249e --- /dev/null +++ b/chatbot/migrations/0033_historicalprofile_latest_flow_used_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-04-04 13:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0032_alter_historicalprofile_first_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('reflection', 'reflection')], max_length=500, null=True), + ), + migrations.AddField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('reflection', 'reflection')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0034_storymedia_file_url_alter_storymedia_file.py b/chatbot/migrations/0034_storymedia_file_url_alter_storymedia_file.py new file mode 100644 index 0000000..9c8506a --- /dev/null +++ b/chatbot/migrations/0034_storymedia_file_url_alter_storymedia_file.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-04-12 09:56 + +import chatbot.models.story_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0033_historicalprofile_latest_flow_used_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='storymedia', + name='file_url', + field=models.CharField(blank=True, max_length=2000, null=True), + ), + migrations.AlterField( + model_name='storymedia', + name='file', + field=models.FileField(blank=True, max_length=1000, null=True, upload_to=chatbot.models.story_models.StoryMedia.get_file_upload_path), + ), + ] diff --git a/chatbot/migrations/0035_companychat_file_url.py b/chatbot/migrations/0035_companychat_file_url.py new file mode 100644 index 0000000..56697e6 --- /dev/null +++ b/chatbot/migrations/0035_companychat_file_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-05-08 06:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0034_storymedia_file_url_alter_storymedia_file'), + ] + + operations = [ + migrations.AddField( + model_name='companychat', + name='file_url', + field=models.CharField(blank=True, max_length=2000, null=True), + ), + ] diff --git a/chatbot/migrations/0036_voice_other_params_alter_voice_provider_and_more.py b/chatbot/migrations/0036_voice_other_params_alter_voice_provider_and_more.py new file mode 100644 index 0000000..dda89f1 --- /dev/null +++ b/chatbot/migrations/0036_voice_other_params_alter_voice_provider_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2025-05-22 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0035_companychat_file_url'), + ] + + operations = [ + migrations.AddField( + model_name='voice', + name='other_params', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='voice', + name='provider', + field=models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('GOOGLE_V1', 'GOOGLE v1 STT'), ('AI4Bharat', 'AI4Bharat'), ('OPENAI_WHISPER', 'OpenAI Whisper'), ('Sarvam', 'Sarvam')], default='AI4Bharat', max_length=300, null=True), + ), + migrations.AlterField( + model_name='voice', + name='type', + field=models.CharField(blank=True, choices=[('SpeechToText', 'Speech To Text'), ('TextToText', 'Text To Text'), ('TextToSpeech', 'Text To Speech'), ('Transliterate', 'Transliteration')], max_length=300, null=True), + ), + ] diff --git a/chatbot/migrations/0037_alter_companybot_llm_model_alter_companychat_stage_and_more.py b/chatbot/migrations/0037_alter_companybot_llm_model_alter_companychat_stage_and_more.py new file mode 100644 index 0000000..5ce103d --- /dev/null +++ b/chatbot/migrations/0037_alter_companybot_llm_model_alter_companychat_stage_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-06-28 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0036_voice_other_params_alter_voice_provider_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='companybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='companychat', + name='stage', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='story', + name='language', + field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu')], default='en', max_length=1000), + ), + ] diff --git a/chatbot/migrations/0038_companystatemachine_use_stage_chats_and_more.py b/chatbot/migrations/0038_companystatemachine_use_stage_chats_and_more.py new file mode 100644 index 0000000..119c147 --- /dev/null +++ b/chatbot/migrations/0038_companystatemachine_use_stage_chats_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-06-28 09:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0037_alter_companybot_llm_model_alter_companychat_stage_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companystatemachine', + name='use_stage_chats', + field=models.BooleanField(default=False, help_text='If True, only chats from this stage will be included and passed to the LLM.', verbose_name='Use Stage Chats'), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='use_stage_chats', + field=models.BooleanField(default=False, help_text='If True, only chats from this stage will be included and passed to the LLM.', verbose_name='Use Stage Chats'), + ), + ] diff --git a/chatbot/migrations/0039_companychat_other_params_and_more.py b/chatbot/migrations/0039_companychat_other_params_and_more.py new file mode 100644 index 0000000..278f21e --- /dev/null +++ b/chatbot/migrations/0039_companychat_other_params_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-07-04 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0038_companystatemachine_use_stage_chats_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companychat', + name='other_params', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'Guided Reflection'), ('oneshot', 'One Step Reflection'), ('shikshalokam_chaupal', 'Shiksha Chaupal'), ('reflection', 'Reflection'), ('creation', 'Creation'), ('megaPTM', 'Mega PTM')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0040_companystatemachine_output_mode_and_more.py b/chatbot/migrations/0040_companystatemachine_output_mode_and_more.py new file mode 100644 index 0000000..b0a0a7e --- /dev/null +++ b/chatbot/migrations/0040_companystatemachine_output_mode_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1.2 on 2025-07-11 09:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0039_companychat_other_params_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companystatemachine', + name='output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Stage If Needed'), ('ENRICH', 'Use Output in Prompt'), ('CUSTOM', 'Custom Logic')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + migrations.AddField( + model_name='companystatemachine', + name='preprocess_bot', + field=models.ForeignKey(blank=True, help_text='Select which Bot to use for preprocessing for complex logic.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preprocess_bots', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='companystatemachine', + name='preprocess_prompt', + field=models.TextField(blank=True, help_text='Define the skip logic prompt if Preprocess Type is SIMPLE. ', null=True), + ), + migrations.AddField( + model_name='companystatemachine', + name='preprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Preprocess Bot')], default='NONE', help_text="Choose how this stage should be preprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Preprocess Bot' lets you select a separate bot to handle complex logic.", max_length=10), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Stage If Needed'), ('ENRICH', 'Use Output in Prompt'), ('CUSTOM', 'Custom Logic')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='preprocess_bot', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Select which Bot to use for preprocessing for complex logic.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='preprocess_prompt', + field=models.TextField(blank=True, help_text='Define the skip logic prompt if Preprocess Type is SIMPLE. ', null=True), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='preprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Preprocess Bot')], default='NONE', help_text="Choose how this stage should be preprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Preprocess Bot' lets you select a separate bot to handle complex logic.", max_length=10), + ), + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0041_rename_output_mode_companystatemachine_preprocess_output_mode_and_more.py b/chatbot/migrations/0041_rename_output_mode_companystatemachine_preprocess_output_mode_and_more.py new file mode 100644 index 0000000..42bd1ed --- /dev/null +++ b/chatbot/migrations/0041_rename_output_mode_companystatemachine_preprocess_output_mode_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1.2 on 2025-07-12 12:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0040_companystatemachine_output_mode_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='companystatemachine', + old_name='output_mode', + new_name='preprocess_output_mode', + ), + migrations.RenameField( + model_name='historicalcompanystatemachine', + old_name='output_mode', + new_name='preprocess_output_mode', + ), + migrations.AddField( + model_name='companystatemachine', + name='postprocess_bot', + field=models.ForeignKey(blank=True, help_text='Select which Bot to use for postprocessing for complex logic.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='postprocess_bots', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='companystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Stage If Needed')], default='NONE', help_text='Define how to use the postprocess output.', max_length=10), + ), + migrations.AddField( + model_name='companystatemachine', + name='postprocess_prompt', + field=models.TextField(blank=True, help_text='Define the postprocess prompt if Postprocess Type is SIMPLE.', null=True), + ), + migrations.AddField( + model_name='companystatemachine', + name='postprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Postprocess Bot')], default='NONE', help_text="Choose how this stage should be postprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Postprocess Bot' lets you select a separate bot to handle complex logic.", max_length=10), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='postprocess_bot', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Select which Bot to use for postprocessing for complex logic.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Stage If Needed')], default='NONE', help_text='Define how to use the postprocess output.', max_length=10), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='postprocess_prompt', + field=models.TextField(blank=True, help_text='Define the postprocess prompt if Postprocess Type is SIMPLE.', null=True), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='postprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Postprocess Bot')], default='NONE', help_text="Choose how this stage should be postprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Postprocess Bot' lets you select a separate bot to handle complex logic.", max_length=10), + ), + ] diff --git a/chatbot/migrations/0042_chatsession_other_params_and_more.py b/chatbot/migrations/0042_chatsession_other_params_and_more.py new file mode 100644 index 0000000..624c464 --- /dev/null +++ b/chatbot/migrations/0042_chatsession_other_params_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.2 on 2025-07-18 07:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0041_rename_output_mode_companystatemachine_preprocess_output_mode_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='chatsession', + name='other_params', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='companystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Next Stage')], default='NONE', help_text='Define how to use the postprocess output.', max_length=10), + ), + migrations.AlterField( + model_name='companystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage'), ('ENRICH', 'Add Output to Prompt'), ('CUSTOM', 'Run Custom Logic')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Next Stage')], default='NONE', help_text='Define how to use the postprocess output.', max_length=10), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage'), ('ENRICH', 'Add Output to Prompt'), ('CUSTOM', 'Run Custom Logic')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + ] diff --git a/chatbot/migrations/0043_alter_historicalprofile_latest_flow_used_and_more.py b/chatbot/migrations/0043_alter_historicalprofile_latest_flow_used_and_more.py new file mode 100644 index 0000000..cddd15f --- /dev/null +++ b/chatbot/migrations/0043_alter_historicalprofile_latest_flow_used_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.2 on 2025-08-04 14:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0042_chatsession_other_params_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + migrations.CreateModel( + name='StoryTranslation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language', models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu')], max_length=10)), + ('title', models.CharField(max_length=1000)), + ('content', models.TextField(blank=True, null=True)), + ('blurb', models.TextField(blank=True, null=True)), + ('tweet', models.TextField(blank=True, null=True)), + ('objective', models.TextField(blank=True, null=True)), + ('action_steps', models.TextField(blank=True, null=True)), + ('impact', models.TextField(blank=True, null=True)), + ('micro_improvement', models.TextField(blank=True, null=True)), + ('translated_other_params', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='chatbot.story')), + ], + options={ + 'indexes': [models.Index(fields=['story', 'language'], name='chatbot_sto_story_i_fd02b4_idx')], + 'unique_together': {('story', 'language')}, + }, + ), + ] diff --git a/chatbot/migrations/0044_storytranslation_formatted_content.py b/chatbot/migrations/0044_storytranslation_formatted_content.py new file mode 100644 index 0000000..f571b67 --- /dev/null +++ b/chatbot/migrations/0044_storytranslation_formatted_content.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-08-05 10:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0043_alter_historicalprofile_latest_flow_used_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='storytranslation', + name='formatted_content', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0045_rename_translated_other_params_storytranslation_other_params.py b/chatbot/migrations/0045_rename_translated_other_params_storytranslation_other_params.py new file mode 100644 index 0000000..f335919 --- /dev/null +++ b/chatbot/migrations/0045_rename_translated_other_params_storytranslation_other_params.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-08-06 07:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0044_storytranslation_formatted_content'), + ] + + operations = [ + migrations.RenameField( + model_name='storytranslation', + old_name='translated_other_params', + new_name='other_params', + ), + ] diff --git a/chatbot/migrations/0046_chatsession_language.py b/chatbot/migrations/0046_chatsession_language.py new file mode 100644 index 0000000..eee1fc9 --- /dev/null +++ b/chatbot/migrations/0046_chatsession_language.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-08-07 08:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0045_rename_translated_other_params_storytranslation_other_params'), + ] + + operations = [ + migrations.AddField( + model_name='chatsession', + name='language', + field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu')], default='en', max_length=1000), + ), + ] diff --git a/chatbot/migrations/0047_companystatemachine_skip_to_step_and_more.py b/chatbot/migrations/0047_companystatemachine_skip_to_step_and_more.py new file mode 100644 index 0000000..7db4214 --- /dev/null +++ b/chatbot/migrations/0047_companystatemachine_skip_to_step_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-08-13 06:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0046_chatsession_language'), + ] + + operations = [ + migrations.AddField( + model_name='companystatemachine', + name='skip_to_step', + field=models.IntegerField(blank=True, help_text='If set, the flow will skip directly to this step number when skip conditions are met.', null=True), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='skip_to_step', + field=models.IntegerField(blank=True, help_text='If set, the flow will skip directly to this step number when skip conditions are met.', null=True), + ), + ] diff --git a/chatbot/migrations/0048_historicaltheme_theme.py b/chatbot/migrations/0048_historicaltheme_theme.py new file mode 100644 index 0000000..0bd8a90 --- /dev/null +++ b/chatbot/migrations/0048_historicaltheme_theme.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.2 on 2025-08-13 11:07 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0047_companystatemachine_skip_to_step_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalTheme', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('themes', models.JSONField(blank=True, default=list, help_text='Store a list of themes associated with this bot.')), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('bot', models.ForeignKey(blank=True, db_constraint=False, help_text='Select the bot this theme belongs to.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical Theme', + 'verbose_name_plural': 'historical Themes', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Theme', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('themes', models.JSONField(blank=True, default=list, help_text='Store a list of themes associated with this bot.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('bot', models.ForeignKey(help_text='Select the bot this theme belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='chatbot.companybot')), + ], + options={ + 'verbose_name': 'Theme', + 'verbose_name_plural': 'Themes', + 'indexes': [models.Index(fields=['bot'], name='chatbot_the_bot_id_22f0dc_idx')], + }, + ), + ] diff --git a/chatbot/migrations/0049_historicaltheme_master_theme_and_more.py b/chatbot/migrations/0049_historicaltheme_master_theme_and_more.py new file mode 100644 index 0000000..07ea46b --- /dev/null +++ b/chatbot/migrations/0049_historicaltheme_master_theme_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.2 on 2025-08-13 11:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0048_historicaltheme_theme'), + ] + + operations = [ + migrations.AddField( + model_name='historicaltheme', + name='master_theme', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='If using a master theme, select the theme to inherit from.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.theme'), + ), + migrations.AddField( + model_name='historicaltheme', + name='theme_type', + field=models.CharField(choices=[('custom', 'Custom for Bot'), ('master', 'Using Master Theme')], default='custom', help_text='Indicates if this theme is custom or uses a master theme.', max_length=10), + ), + migrations.AddField( + model_name='theme', + name='master_theme', + field=models.ForeignKey(blank=True, help_text='If using a master theme, select the theme to inherit from.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_themes', to='chatbot.theme'), + ), + migrations.AddField( + model_name='theme', + name='theme_type', + field=models.CharField(choices=[('custom', 'Custom for Bot'), ('master', 'Using Master Theme')], default='custom', help_text='Indicates if this theme is custom or uses a master theme.', max_length=10), + ), + migrations.AddIndex( + model_name='theme', + index=models.Index(fields=['theme_type'], name='chatbot_the_theme_t_f3c34f_idx'), + ), + ] diff --git a/chatbot/migrations/0050_alter_tag_options_media_extracted_text_media_tags_and_more.py b/chatbot/migrations/0050_alter_tag_options_media_extracted_text_media_tags_and_more.py new file mode 100644 index 0000000..9c1ab7d --- /dev/null +++ b/chatbot/migrations/0050_alter_tag_options_media_extracted_text_media_tags_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.2 on 2025-08-20 10:48 + +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0049_historicaltheme_master_theme_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='tag', + options={'verbose_name': 'Global Tag', 'verbose_name_plural': 'Global Tags'}, + ), + migrations.AddField( + model_name='media', + name='extracted_text', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='media', + name='tags', + field=models.ManyToManyField(related_name='medias', to='chatbot.tag'), + ), + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('application/msword', 'DOC'), ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'DOCX'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('application/vnd.ms-excel', 'XLS'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX')], default='text/plain', max_length=100), + ), + migrations.AddIndex( + model_name='media', + index=django.contrib.postgres.indexes.GinIndex(django.contrib.postgres.search.SearchVector('extracted_text', config='english'), name='media_extracted_text_gin'), + ), + ] diff --git a/chatbot/migrations/0051_media_media_extracted_text_trgm.py b/chatbot/migrations/0051_media_media_extracted_text_trgm.py new file mode 100644 index 0000000..3c730a4 --- /dev/null +++ b/chatbot/migrations/0051_media_media_extracted_text_trgm.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.2 on 2025-08-21 01:14 + +import django.contrib.postgres.indexes +from django.db import migrations +from django.contrib.postgres.operations import TrigramExtension + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0050_alter_tag_options_media_extracted_text_media_tags_and_more'), + ] + + operations = [ + TrigramExtension(), + migrations.AddIndex( + model_name='media', + index=django.contrib.postgres.indexes.GinIndex(fields=['extracted_text'], name='media_extracted_text_trgm', opclasses=['gin_trgm_ops']), + ), + ] diff --git a/chatbot/migrations/0052_tag_source_type.py b/chatbot/migrations/0052_tag_source_type.py new file mode 100644 index 0000000..d9fa4aa --- /dev/null +++ b/chatbot/migrations/0052_tag_source_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-08-22 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0051_media_media_extracted_text_trgm'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='source_type', + field=models.CharField(blank=True, choices=[('MANUAL', 'Manual'), ('AI_EXTRACTED', 'AI Extracted from Document'), ('AI_GENERATED', 'AI Generated')], max_length=50, null=True), + ), + ] diff --git a/chatbot/migrations/0053_alter_tag_source_type_mediaimage.py b/chatbot/migrations/0053_alter_tag_source_type_mediaimage.py new file mode 100644 index 0000000..9e5cb89 --- /dev/null +++ b/chatbot/migrations/0053_alter_tag_source_type_mediaimage.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.2 on 2025-08-29 07:37 + +import chatbot.models.media_models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0052_tag_source_type'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='source_type', + field=models.CharField(blank=True, choices=[('MANUAL', 'Manual'), ('AI_EXTRACTED', 'AI Extracted'), ('AI_GENERATED', 'AI Generated')], max_length=50, null=True), + ), + migrations.CreateModel( + name='MediaImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=1000)), + ('file', models.FileField(blank=True, max_length=1000, null=True, upload_to=chatbot.models.media_models.MediaImage.get_file_upload_path)), + ('page', models.IntegerField(blank=True, null=True)), + ('index', models.IntegerField(default=0)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('media_type', models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP'), ('image/heif', 'HEIF'), ('image/heic', 'HEIC')], max_length=100, null=True)), + ('base64_str', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='chatbot.media')), + ], + options={ + 'ordering': ['page', 'index'], + }, + ), + ] diff --git a/chatbot/migrations/0054_tag_description_alter_media_media_type.py b/chatbot/migrations/0054_tag_description_alter_media_media_type.py new file mode 100644 index 0000000..6438cb0 --- /dev/null +++ b/chatbot/migrations/0054_tag_description_alter_media_media_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-08-30 08:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0053_alter_tag_source_type_mediaimage'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('application/msword', 'DOC'), ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'DOCX'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('application/vnd.ms-excel', 'XLS'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX'), ('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'PPTX'), ('application/vnd.ms-powerpoint', 'PPT')], default='text/plain', max_length=100), + ), + ] diff --git a/chatbot/migrations/0055_media_parent.py b/chatbot/migrations/0055_media_parent.py new file mode 100644 index 0000000..6290962 --- /dev/null +++ b/chatbot/migrations/0055_media_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-09-01 06:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0054_tag_description_alter_media_media_type'), + ] + + operations = [ + migrations.AddField( + model_name='media', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subdocuments', to='chatbot.media'), + ), + ] diff --git a/chatbot/migrations/0056_companybot_other_params_and_more.py b/chatbot/migrations/0056_companybot_other_params_and_more.py new file mode 100644 index 0000000..a4e8737 --- /dev/null +++ b/chatbot/migrations/0056_companybot_other_params_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2025-09-04 09:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0055_media_parent'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='other_params', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='other_params', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='media', + name='media_type', + field=models.CharField(choices=[('application/pdf', 'PDF'), ('application/msword', 'DOC'), ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'DOCX'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('application/vnd.ms-excel', 'XLS'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX')], default='text/plain', max_length=100), + ), + ] diff --git a/chatbot/migrations/0057_historicalmedia.py b/chatbot/migrations/0057_historicalmedia.py new file mode 100644 index 0000000..47252aa --- /dev/null +++ b/chatbot/migrations/0057_historicalmedia.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.2 on 2025-09-05 03:23 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0056_companybot_other_params_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalMedia', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(max_length=1000)), + ('url', models.URLField(blank=True, max_length=1000, null=True)), + ('priority', models.CharField(choices=[('P1', 'P1'), ('P2', 'P2'), ('P3', 'P3')], default='P1', max_length=50)), + ('media_type', models.CharField(choices=[('application/pdf', 'PDF'), ('application/msword', 'DOC'), ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'DOCX'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('application/vnd.ms-excel', 'XLS'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX')], default='text/plain', max_length=100)), + ('file', models.TextField(max_length=1000)), + ('description', models.TextField(blank=True, null=True)), + ('extracted_text', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company_bot', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.media')), + ], + options={ + 'verbose_name': 'historical media', + 'verbose_name_plural': 'historical medias', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/chatbot/migrations/0058_alter_keyvalue_value.py b/chatbot/migrations/0058_alter_keyvalue_value.py new file mode 100644 index 0000000..1db14be --- /dev/null +++ b/chatbot/migrations/0058_alter_keyvalue_value.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-09-09 09:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0057_historicalmedia'), + ] + + operations = [ + migrations.AlterField( + model_name='keyvalue', + name='value', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0059_company_url.py b/chatbot/migrations/0059_company_url.py new file mode 100644 index 0000000..4b5ffdd --- /dev/null +++ b/chatbot/migrations/0059_company_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-09-11 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0058_alter_keyvalue_value'), + ] + + operations = [ + migrations.AddField( + model_name='company', + name='url', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0060_historicalmedia_organization_media_organization.py b/chatbot/migrations/0060_historicalmedia_organization_media_organization.py new file mode 100644 index 0000000..8edf00e --- /dev/null +++ b/chatbot/migrations/0060_historicalmedia_organization_media_organization.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-09-12 01:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0059_company_url'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='organization', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.company'), + ), + migrations.AddField( + model_name='media', + name='organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chatbot.company'), + ), + ] diff --git a/chatbot/migrations/0061_companystatemachine_text_conversion_type_and_more.py b/chatbot/migrations/0061_companystatemachine_text_conversion_type_and_more.py new file mode 100644 index 0000000..2157621 --- /dev/null +++ b/chatbot/migrations/0061_companystatemachine_text_conversion_type_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.2 on 2025-09-14 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0060_historicalmedia_organization_media_organization'), + ] + + operations = [ + migrations.AddField( + model_name='companystatemachine', + name='text_conversion_type', + field=models.CharField(choices=[('TRANSLATE', 'Translation'), ('TRANSLITERATE', 'Transliteration')], default='TRANSLATE', help_text="Choose how to process this field's text: 'Translation' converts meaning into another language, 'Transliteration' preserves sound using another script.", max_length=15), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='text_conversion_type', + field=models.CharField(choices=[('TRANSLATE', 'Translation'), ('TRANSLITERATE', 'Transliteration')], default='TRANSLATE', help_text="Choose how to process this field's text: 'Translation' converts meaning into another language, 'Transliteration' preserves sound using another script.", max_length=15), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'Guided Reflection'), ('oneshot', 'One Step Reflection'), ('shikshalokam_chaupal', 'Shiksha Chaupal'), ('reflection', 'Reflection'), ('creation', 'Creation'), ('megaPTM', 'Mega PTM'), ('listening-activity', 'Listening Activity')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0062_companystatemachine_tool_context_and_more.py b/chatbot/migrations/0062_companystatemachine_tool_context_and_more.py new file mode 100644 index 0000000..6dc86e7 --- /dev/null +++ b/chatbot/migrations/0062_companystatemachine_tool_context_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-09-24 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0061_companystatemachine_text_conversion_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companystatemachine', + name='tool_context', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='tool_context', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0063_historicalmedia_display_mode_media_display_mode.py b/chatbot/migrations/0063_historicalmedia_display_mode_media_display_mode.py new file mode 100644 index 0000000..aa6079c --- /dev/null +++ b/chatbot/migrations/0063_historicalmedia_display_mode_media_display_mode.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-10-08 05:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0062_companystatemachine_tool_context_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='display_mode', + field=models.CharField(choices=[('visible', 'Visible to All'), ('ai_only', 'AI Only (Hidden from UI)'), ('private', 'Private (Hidden from UI and AI)')], default='visible', max_length=20), + ), + migrations.AddField( + model_name='media', + name='display_mode', + field=models.CharField(choices=[('visible', 'Visible to All'), ('ai_only', 'AI Only (Hidden from UI)'), ('private', 'Private (Hidden from UI and AI)')], default='visible', max_length=20), + ), + ] diff --git a/chatbot/migrations/0064_storytranslation_location_and_more.py b/chatbot/migrations/0064_storytranslation_location_and_more.py new file mode 100644 index 0000000..d2e8169 --- /dev/null +++ b/chatbot/migrations/0064_storytranslation_location_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-10-14 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0063_historicalmedia_display_mode_media_display_mode'), + ] + + operations = [ + migrations.AddField( + model_name='storytranslation', + name='location', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'Guided Reflection'), ('oneshot', 'One Step Reflection'), ('shikshalokam_chaupal', 'Shiksha Chaupal'), ('reflection', 'Reflection'), ('creation', 'Creation'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('listening-activity', 'Listening Activity')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0065_story_block_story_district_story_state_and_more.py b/chatbot/migrations/0065_story_block_story_district_story_state_and_more.py new file mode 100644 index 0000000..0691816 --- /dev/null +++ b/chatbot/migrations/0065_story_block_story_district_story_state_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.2 on 2025-10-17 09:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0064_storytranslation_location_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='story', + name='block', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='story', + name='district', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='story', + name='state', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='storytranslation', + name='block', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='storytranslation', + name='district', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='storytranslation', + name='state', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/chatbot/migrations/0066_alter_botvernacular_unique_together_and_more.py b/chatbot/migrations/0066_alter_botvernacular_unique_together_and_more.py new file mode 100644 index 0000000..c6a5a3d --- /dev/null +++ b/chatbot/migrations/0066_alter_botvernacular_unique_together_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.2 on 2025-11-12 18:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0065_story_block_story_district_story_state_and_more'), + ] + + operations = [ + # migrations.AlterUniqueTogether( + # name='botvernacular', + # unique_together=set(), + # ), + migrations.AlterUniqueTogether( + name='storyvernacular', + unique_together=set(), + ), + migrations.AlterField( + model_name='botvernacular', + name='company_bot', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bot_vernacular', to='chatbot.companybot'), + ), + migrations.AlterField( + model_name='storyvernacular', + name='company_bot', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='story_vernacular', to='chatbot.companybot'), + ), + ] diff --git a/chatbot/migrations/0066_historicalmedia_markdown_file_media_markdown_file.py b/chatbot/migrations/0066_historicalmedia_markdown_file_media_markdown_file.py new file mode 100644 index 0000000..40017f0 --- /dev/null +++ b/chatbot/migrations/0066_historicalmedia_markdown_file_media_markdown_file.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-10-31 09:01 + +import chatbot.models.media_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0065_story_block_story_district_story_state_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='markdown_file', + field=models.TextField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='media', + name='markdown_file', + field=models.FileField(blank=True, max_length=1000, null=True, upload_to=chatbot.models.media_models.Media.get_file_upload_path), + ), + ] diff --git a/chatbot/migrations/0067_merge_20251116_1325.py b/chatbot/migrations/0067_merge_20251116_1325.py new file mode 100644 index 0000000..70037df --- /dev/null +++ b/chatbot/migrations/0067_merge_20251116_1325.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-11-16 07:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0066_alter_botvernacular_unique_together_and_more'), + ('chatbot', '0066_historicalmedia_markdown_file_media_markdown_file'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0068_companybot_chat_history_limit_and_more.py b/chatbot/migrations/0068_companybot_chat_history_limit_and_more.py new file mode 100644 index 0000000..e2e3dd1 --- /dev/null +++ b/chatbot/migrations/0068_companybot_chat_history_limit_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.2 on 2025-12-13 14:05 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0067_merge_20251116_1325'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='chat_history_limit', + field=models.IntegerField(default=1000, help_text='Controls how many of the most recent chat messages are included as conversation history when making an LLM request.', validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='chat_history_limit', + field=models.IntegerField(default=1000, help_text='Controls how many of the most recent chat messages are included as conversation history when making an LLM request.', validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'Guided Reflection'), ('oneshot', 'One Step Reflection'), ('shikshalokam_chaupal', 'Shiksha Chaupal'), ('reflection', 'Reflection'), ('creation', 'Creation'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('listening-activity', 'Listening Activity'), ('parent_perception_survey', 'Parent Perception Survey')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('parent_perception_survey', 'Parent Perception Survey'), ('creation', 'Creation')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('parent_perception_survey', 'Parent Perception Survey'), ('creation', 'Creation')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0068_companybot_strategy_and_more.py b/chatbot/migrations/0068_companybot_strategy_and_more.py new file mode 100644 index 0000000..705505a --- /dev/null +++ b/chatbot/migrations/0068_companybot_strategy_and_more.py @@ -0,0 +1,264 @@ +# Generated by Django 5.1.2 on 2025-11-29 06:14 + +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0067_merge_20251116_1325'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='strategy', + field=models.CharField(blank=True, choices=[('oneshot', 'One Shot'), ('guided_guest', 'Guided Guest'), ('guest_discussion', 'Guest Discussion'), ('common', 'Common')], help_text='Select the strategy or approach this bot uses for conversations.', max_length=100, null=True), + ), + migrations.AddField( + model_name='companystatemachine', + name='operation_type', + field=models.CharField(choices=[('llm', 'LLM'), ('non_llm', 'Non-LLM')], default='llm', help_text='Choose whether this state uses LLM or non-LLM processing.', max_length=20), + ), + migrations.AddField( + model_name='companystatemachine', + name='skip_if_authenticated', + field=models.BooleanField(default=False, help_text='If True, this state will be skipped for authenticated users.'), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='strategy', + field=models.CharField(blank=True, choices=[('oneshot', 'One Shot'), ('guided_guest', 'Guided Guest'), ('guest_discussion', 'Guest Discussion'), ('common', 'Common')], help_text='Select the strategy or approach this bot uses for conversations.', max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='operation_type', + field=models.CharField(choices=[('llm', 'LLM'), ('non_llm', 'Non-LLM')], default='llm', help_text='Choose whether this state uses LLM or non-LLM processing.', max_length=20), + ), + migrations.AddField( + model_name='historicalcompanystatemachine', + name='skip_if_authenticated', + field=models.BooleanField(default=False, help_text='If True, this state will be skipped for authenticated users.'), + ), + migrations.CreateModel( + name='HistoricalI18nTag', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('tag_name', models.CharField(db_index=True, help_text="Unique tag name for grouping translations (e.g., 'welcome_message', 'button_labels').", max_length=255)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical I18n Tag', + 'verbose_name_plural': 'historical I18n Tags', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalPDFTemplates', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('template', models.TextField(help_text='Template content for PDF generation (e.g., HTML, EJS template).')), + ('template_name', models.CharField(db_index=True, help_text='Unique name identifier for this template.', max_length=255)), + ('user_type', models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All')], default='all', help_text='User types that can use this template (guest, auth, or all).', max_length=20)), + ('constants_json', models.JSONField(blank=True, help_text='JSON object containing constants/variables used in the template.', null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical PDF Template', + 'verbose_name_plural': 'historical PDF Templates', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='I18nTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag_name', models.CharField(help_text="Unique tag name for grouping translations (e.g., 'welcome_message', 'button_labels').", max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'I18n Tag', + 'verbose_name_plural': 'I18n Tags', + 'ordering': ['tag_name'], + 'indexes': [models.Index(fields=['tag_name'], name='chatbot_i18_tag_nam_80094f_idx')], + }, + ), + migrations.CreateModel( + name='HistoricalI18nTranslation', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('variable_name', models.CharField(help_text="Variable name for this translation (e.g., 'title', 'description', 'button_text').", max_length=255)), + ('language', models.CharField(help_text="Language code (e.g., 'en', 'hi', 'kn', 'te').", max_length=10)), + ('value', models.TextField(help_text='Translated text value for this variable in the specified language.')), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('tag_id', models.ForeignKey(blank=True, db_constraint=False, help_text='The tag this translation belongs to.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.i18ntag')), + ], + options={ + 'verbose_name': 'historical I18n Translation', + 'verbose_name_plural': 'historical I18n Translations', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='ImageConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name for this image configuration.', max_length=100)), + ('max_images', models.IntegerField(default=1, help_text='Maximum number of images allowed.', validators=[django.core.validators.MinValueValidator(0)])), + ('image_size', models.IntegerField(default=5242880, help_text='Maximum image size in bytes (default: 5MB).', validators=[django.core.validators.MinValueValidator(1)])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Image Configuration', + 'verbose_name_plural': 'Image Configurations', + 'indexes': [models.Index(fields=['name'], name='chatbot_ima_name_1525db_idx')], + }, + ), + migrations.CreateModel( + name='Flow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('flow_name', models.CharField(help_text='Name of the flow.', max_length=255)), + ('flow_route', models.CharField(help_text='Route/path for accessing this flow.', max_length=255)), + ('languages', models.JSONField(default=list, help_text="List of supported language codes (e.g., ['en', 'hi', 'kn']).")), + ('hidden', models.BooleanField(default=False, help_text='If True, this flow will be hidden from public listing.')), + ('active', models.BooleanField(default=True, help_text='If False, this flow will be disabled and not accessible.')), + ('websocket_url', models.URLField(blank=True, help_text='WebSocket URL for real-time communication (optional).', max_length=500, null=True)), + ('user_type', models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All')], default='all', help_text='User types allowed to access this flow (guest, auth, or all).', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('bot_id', models.ForeignKey(help_text='The main bot associated with this flow.', on_delete=django.db.models.deletion.CASCADE, related_name='flows', to='chatbot.companybot')), + ('parent_flow_id', models.ForeignKey(blank=True, help_text='Parent flow if this is a sub-flow.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_flows', to='chatbot.flow')), + ('story_bot_id', models.ForeignKey(blank=True, help_text='Optional secondary bot for story-related functionality.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='story_flows', to='chatbot.companybot')), + ('image_config_id', models.ForeignKey(blank=True, help_text='Image configuration settings for this flow.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='flows', to='chatbot.imageconfiguration')), + ], + options={ + 'verbose_name': 'Flow', + 'verbose_name_plural': 'Flows', + }, + ), + migrations.CreateModel( + name='PDFTemplates', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('template', models.TextField(help_text='Template content for PDF generation (e.g., HTML, EJS template).')), + ('template_name', models.CharField(help_text='Unique name identifier for this template.', max_length=255, unique=True)), + ('user_type', models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All')], default='all', help_text='User types that can use this template (guest, auth, or all).', max_length=20)), + ('constants_json', models.JSONField(blank=True, help_text='JSON object containing constants/variables used in the template.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'PDF Template', + 'verbose_name_plural': 'PDF Templates', + 'indexes': [models.Index(fields=['template_name'], name='chatbot_pdf_templat_b240ab_idx'), models.Index(fields=['user_type'], name='chatbot_pdf_user_ty_3368e4_idx')], + }, + ), + migrations.CreateModel( + name='HistoricalFlow', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('flow_name', models.CharField(help_text='Name of the flow.', max_length=255)), + ('flow_route', models.CharField(help_text='Route/path for accessing this flow.', max_length=255)), + ('languages', models.JSONField(default=list, help_text="List of supported language codes (e.g., ['en', 'hi', 'kn']).")), + ('hidden', models.BooleanField(default=False, help_text='If True, this flow will be hidden from public listing.')), + ('active', models.BooleanField(default=True, help_text='If False, this flow will be disabled and not accessible.')), + ('websocket_url', models.URLField(blank=True, help_text='WebSocket URL for real-time communication (optional).', max_length=500, null=True)), + ('user_type', models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All')], default='all', help_text='User types allowed to access this flow (guest, auth, or all).', max_length=20)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('bot_id', models.ForeignKey(blank=True, db_constraint=False, help_text='The main bot associated with this flow.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('parent_flow_id', models.ForeignKey(blank=True, db_constraint=False, help_text='Parent flow if this is a sub-flow.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.flow')), + ('story_bot_id', models.ForeignKey(blank=True, db_constraint=False, help_text='Optional secondary bot for story-related functionality.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('image_config_id', models.ForeignKey(blank=True, db_constraint=False, help_text='Image configuration settings for this flow.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.imageconfiguration')), + ('template_id', models.ForeignKey(blank=True, db_constraint=False, help_text='PDF template for generating documents in this flow.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.pdftemplates')), + ], + options={ + 'verbose_name': 'historical Flow', + 'verbose_name_plural': 'historical Flows', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name='flow', + name='template_id', + field=models.ForeignKey(blank=True, help_text='PDF template for generating documents in this flow.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='flows', to='chatbot.pdftemplates'), + ), + migrations.CreateModel( + name='I18nTranslation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('variable_name', models.CharField(help_text="Variable name for this translation (e.g., 'title', 'description', 'button_text').", max_length=255)), + ('language', models.CharField(help_text="Language code (e.g., 'en', 'hi', 'kn', 'te').", max_length=10)), + ('value', models.TextField(help_text='Translated text value for this variable in the specified language.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('tag_id', models.ForeignKey(help_text='The tag this translation belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='chatbot.i18ntag')), + ], + options={ + 'verbose_name': 'I18n Translation', + 'verbose_name_plural': 'I18n Translations', + 'ordering': ['tag_id', 'variable_name', 'language'], + 'indexes': [models.Index(fields=['tag_id', 'variable_name', 'language'], name='chatbot_i18_tag_id__7eb096_idx'), models.Index(fields=['language'], name='chatbot_i18_languag_e2489a_idx'), models.Index(fields=['variable_name'], name='chatbot_i18_variabl_04d419_idx')], + 'unique_together': {('tag_id', 'variable_name', 'language')}, + }, + ), + migrations.AddIndex( + model_name='flow', + index=models.Index(fields=['flow_route'], name='chatbot_flo_flow_ro_f6fbff_idx'), + ), + migrations.AddIndex( + model_name='flow', + index=models.Index(fields=['bot_id'], name='chatbot_flo_bot_id__f110e9_idx'), + ), + migrations.AddIndex( + model_name='flow', + index=models.Index(fields=['active'], name='chatbot_flo_active_a1007c_idx'), + ), + migrations.AddIndex( + model_name='flow', + index=models.Index(fields=['hidden'], name='chatbot_flo_hidden_55cd22_idx'), + ), + migrations.AlterUniqueTogether( + name='flow', + unique_together={('flow_route', 'bot_id')}, + ), + ] diff --git a/chatbot/migrations/0069_alter_historicalprofile_latest_flow_used_and_more.py b/chatbot/migrations/0069_alter_historicalprofile_latest_flow_used_and_more.py new file mode 100644 index 0000000..ea18513 --- /dev/null +++ b/chatbot/migrations/0069_alter_historicalprofile_latest_flow_used_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-12-01 10:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0068_companybot_strategy_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('school-survey', 'school-survey'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('school-survey', 'school-survey'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC')], max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0069_historicalmedia_download_count_and_more.py b/chatbot/migrations/0069_historicalmedia_download_count_and_more.py new file mode 100644 index 0000000..f445c7c --- /dev/null +++ b/chatbot/migrations/0069_historicalmedia_download_count_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-12-29 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0068_companybot_chat_history_limit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='download_count', + field=models.PositiveBigIntegerField(default=0), + ), + migrations.AddField( + model_name='historicalmedia', + name='view_count', + field=models.PositiveBigIntegerField(default=0), + ), + migrations.AddField( + model_name='media', + name='download_count', + field=models.PositiveBigIntegerField(default=0), + ), + migrations.AddField( + model_name='media', + name='view_count', + field=models.PositiveBigIntegerField(default=0), + ), + ] diff --git a/chatbot/migrations/0070_alter_flow_websocket_url_and_more.py b/chatbot/migrations/0070_alter_flow_websocket_url_and_more.py new file mode 100644 index 0000000..9fc3284 --- /dev/null +++ b/chatbot/migrations/0070_alter_flow_websocket_url_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-12-16 05:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0069_alter_historicalprofile_latest_flow_used_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='flow', + name='websocket_url', + field=models.CharField(blank=True, help_text='WebSocket path for real-time communication (e.g., ws/common). Do not include protocol or host.', max_length=500, null=True), + ), + migrations.AlterField( + model_name='historicalflow', + name='websocket_url', + field=models.CharField(blank=True, help_text='WebSocket path for real-time communication (e.g., ws/common). Do not include protocol or host.', max_length=500, null=True), + ), + ] diff --git a/chatbot/migrations/0070_historicalmedia_thumbnail_media_thumbnail.py b/chatbot/migrations/0070_historicalmedia_thumbnail_media_thumbnail.py new file mode 100644 index 0000000..b357929 --- /dev/null +++ b/chatbot/migrations/0070_historicalmedia_thumbnail_media_thumbnail.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-12-30 05:43 + +import chatbot.models.media_models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0069_historicalmedia_download_count_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='thumbnail', + field=models.TextField(blank=True, help_text='Auto-generated preview thumbnail', max_length=1000, null=True), + ), + migrations.AddField( + model_name='media', + name='thumbnail', + field=models.ImageField(blank=True, help_text='Auto-generated preview thumbnail', max_length=1000, null=True, upload_to=chatbot.models.media_models.Media.get_thumbnail_upload_path), + ), + ] diff --git a/chatbot/migrations/0071_add_stream_field_to_company_bot.py b/chatbot/migrations/0071_add_stream_field_to_company_bot.py new file mode 100644 index 0000000..1a83a8e --- /dev/null +++ b/chatbot/migrations/0071_add_stream_field_to_company_bot.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2026-01-13 07:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0070_historicalmedia_thumbnail_media_thumbnail'), + ] + + operations = [ + migrations.AddField( + model_name='companybot', + name='stream', + field=models.BooleanField(default=True, help_text='Enable streaming mode for LLM responses. When enabled, the bot will send response chunks in real-time as they are generated (streaming). When disabled, the bot will wait for the complete response before sending it (non-streaming). Streaming provides better user experience with faster perceived response times, while non-streaming ensures complete responses are received at once.'), + ), + migrations.AddField( + model_name='historicalcompanybot', + name='stream', + field=models.BooleanField(default=True, help_text='Enable streaming mode for LLM responses. When enabled, the bot will send response chunks in real-time as they are generated (streaming). When disabled, the bot will wait for the complete response before sending it (non-streaming). Streaming provides better user experience with faster perceived response times, while non-streaming ensures complete responses are received at once.'), + ), + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, choices=[('normal', 'Guided Reflection'), ('oneshot', 'One Step Reflection'), ('shikshalokam_chaupal', 'Shiksha Chaupal'), ('reflection', 'Reflection'), ('creation', 'Creation'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('listening-activity', 'Listening Activity'), ('parent_perception_survey', 'Parent Perception Survey'), ('lcf', 'LCF'), ('lfa', 'LFA'), ('free_flow', 'Free-Flow')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0071_merge_20251218_1654.py b/chatbot/migrations/0071_merge_20251218_1654.py new file mode 100644 index 0000000..a522a13 --- /dev/null +++ b/chatbot/migrations/0071_merge_20251218_1654.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-12-18 11:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0068_companybot_chat_history_limit_and_more'), + ('chatbot', '0070_alter_flow_websocket_url_and_more'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0072_alter_companybot_stream_and_more.py b/chatbot/migrations/0072_alter_companybot_stream_and_more.py new file mode 100644 index 0000000..7098d30 --- /dev/null +++ b/chatbot/migrations/0072_alter_companybot_stream_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2026-01-13 07:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0071_add_stream_field_to_company_bot'), + ] + + operations = [ + migrations.AlterField( + model_name='companybot', + name='stream', + field=models.BooleanField(default=False, help_text='Enable streaming mode for LLM responses.'), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='stream', + field=models.BooleanField(default=False, help_text='Enable streaming mode for LLM responses.'), + ), + ] diff --git a/chatbot/migrations/0072_alter_flow_unique_together_and_more.py b/chatbot/migrations/0072_alter_flow_unique_together_and_more.py new file mode 100644 index 0000000..54f63eb --- /dev/null +++ b/chatbot/migrations/0072_alter_flow_unique_together_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.1.2 on 2025-12-20 15:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0071_merge_20251218_1654'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='flow', + unique_together=set(), + ), + migrations.RemoveField( + model_name='historicalflow', + name='template_id', + ), + migrations.AddField( + model_name='historicalpdftemplates', + name='flow', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Flow associated with this template.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.flow'), + ), + migrations.AddField( + model_name='pdftemplates', + name='flow', + field=models.ForeignKey(blank=True, help_text='Flow associated with this template.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pdf_templates', to='chatbot.flow'), + ), + migrations.AlterField( + model_name='flow', + name='flow_route', + field=models.CharField(help_text='Route/path for accessing this flow.', max_length=255, unique=True), + ), + migrations.AlterField( + model_name='flow', + name='languages', + field=models.JSONField(default=['en', 'hi', 'kn', 'te'], help_text="List of supported language codes (e.g., ['en', 'hi', 'kn'])."), + ), + migrations.AlterField( + model_name='flow', + name='websocket_url', + field=models.CharField(default='ws/common/', help_text='WebSocket path for real-time communication (e.g., ws/common). Do not include protocol or host.', max_length=500), + ), + migrations.AlterField( + model_name='historicalflow', + name='flow_route', + field=models.CharField(db_index=True, help_text='Route/path for accessing this flow.', max_length=255), + ), + migrations.AlterField( + model_name='historicalflow', + name='languages', + field=models.JSONField(default=['en', 'hi', 'kn', 'te'], help_text="List of supported language codes (e.g., ['en', 'hi', 'kn'])."), + ), + migrations.AlterField( + model_name='historicalflow', + name='websocket_url', + field=models.CharField(default='ws/common/', help_text='WebSocket path for real-time communication (e.g., ws/common). Do not include protocol or host.', max_length=500), + ), + migrations.AlterField( + model_name='historicalprofile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('school-survey', 'school-survey'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('parent_perception_survey', 'Parent Perception Survey'), ('creation', 'Creation')], max_length=500, null=True), + ), + migrations.AlterField( + model_name='profile', + name='latest_flow_used', + field=models.CharField(blank=True, choices=[('guest-discussion', 'guest-discussion'), ('login-discussion', 'login-discussion'), ('guest-mi-story', 'guest-mi-story'), ('school-survey', 'school-survey'), ('listening-activity', 'Listening Activity'), ('login', 'login'), ('sso', 'sso'), ('reflection', 'reflection'), ('megaPTM', 'Mega PTM'), ('YLC', 'YLC'), ('parent_perception_survey', 'Parent Perception Survey'), ('creation', 'Creation')], max_length=500, null=True), + ), + migrations.RemoveField( + model_name='flow', + name='template_id', + ), + ] diff --git a/chatbot/migrations/0073_historicalmedia_external_file_id_and_more.py b/chatbot/migrations/0073_historicalmedia_external_file_id_and_more.py new file mode 100644 index 0000000..aa3d006 --- /dev/null +++ b/chatbot/migrations/0073_historicalmedia_external_file_id_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.2 on 2026-01-17 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0072_alter_companybot_stream_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmedia', + name='external_file_id', + field=models.CharField(blank=True, help_text='External provider file identifier used for vector indexing (e.g. OpenAI Files API file_id)', max_length=300, null=True), + ), + migrations.AddField( + model_name='media', + name='external_file_id', + field=models.CharField(blank=True, help_text='External provider file identifier used for vector indexing (e.g. OpenAI Files API file_id)', max_length=300, null=True), + ), + migrations.AlterField( + model_name='companystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=10), + ), + migrations.AlterField( + model_name='mediaimage', + name='media_type', + field=models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP'), ('image/heif', 'HEIF'), ('image/heic', 'HEIC'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='storymedia', + name='media_type', + field=models.CharField(blank=True, choices=[('application/pdf', 'PDF'), ('text/plain', 'TXT'), ('text/csv', 'CSV'), ('image/jpeg', 'JPEG'), ('image/png', 'PNG'), ('image/svg+xml', 'SVG'), ('image/webp', 'WEBP'), ('image/heif', 'HEIF'), ('image/heic', 'HEIC'), ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'XLSX')], max_length=100, null=True), + ), + ] diff --git a/chatbot/migrations/0073_remove_flow_chatbot_flo_bot_id__f110e9_idx_and_more.py b/chatbot/migrations/0073_remove_flow_chatbot_flo_bot_id__f110e9_idx_and_more.py new file mode 100644 index 0000000..a0a7f87 --- /dev/null +++ b/chatbot/migrations/0073_remove_flow_chatbot_flo_bot_id__f110e9_idx_and_more.py @@ -0,0 +1,72 @@ +# Generated by Django 5.1.2 on 2025-12-21 09:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0072_alter_flow_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='flow', + name='chatbot_flo_bot_id__f110e9_idx', + ), + migrations.RenameField( + model_name='flow', + old_name='bot_id', + new_name='bot', + ), + migrations.RenameField( + model_name='flow', + old_name='image_config_id', + new_name='image_config', + ), + migrations.RenameField( + model_name='flow', + old_name='parent_flow_id', + new_name='parent_flow', + ), + migrations.RenameField( + model_name='flow', + old_name='story_bot_id', + new_name='story_bot', + ), + migrations.RenameField( + model_name='historicalflow', + old_name='bot_id', + new_name='bot', + ), + migrations.RenameField( + model_name='historicalflow', + old_name='image_config_id', + new_name='image_config', + ), + migrations.RenameField( + model_name='historicalflow', + old_name='parent_flow_id', + new_name='parent_flow', + ), + migrations.RenameField( + model_name='historicalflow', + old_name='story_bot_id', + new_name='story_bot', + ), + migrations.AddField( + model_name='flow', + name='story_validation_bot', + field=models.ForeignKey(blank=True, help_text='Optional secondary bot for story-related functionality.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='story_validation_flows', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='historicalflow', + name='story_validation_bot', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Optional secondary bot for story-related functionality.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot'), + ), + migrations.AddIndex( + model_name='flow', + index=models.Index(fields=['bot'], name='chatbot_flo_bot_id_58878f_idx'), + ), + ] diff --git a/chatbot/migrations/0074_flow_create_story_historicalflow_create_story.py b/chatbot/migrations/0074_flow_create_story_historicalflow_create_story.py new file mode 100644 index 0000000..62442ff --- /dev/null +++ b/chatbot/migrations/0074_flow_create_story_historicalflow_create_story.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-12-22 14:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0073_remove_flow_chatbot_flo_bot_id__f110e9_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='flow', + name='create_story', + field=models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All'), ('none', 'None')], default='all', help_text='Whether to post process the story or not', max_length=20), + ), + migrations.AddField( + model_name='historicalflow', + name='create_story', + field=models.CharField(choices=[('guest', 'Guest'), ('auth', 'Authenticated'), ('all', 'All'), ('none', 'None')], default='all', help_text='Whether to post process the story or not', max_length=20), + ), + ] diff --git a/chatbot/migrations/0075_historicalimageconfiguration_historicalvoice.py b/chatbot/migrations/0075_historicalimageconfiguration_historicalvoice.py new file mode 100644 index 0000000..106e7ed --- /dev/null +++ b/chatbot/migrations/0075_historicalimageconfiguration_historicalvoice.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.2 on 2025-12-22 14:36 + +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0074_flow_create_story_historicalflow_create_story'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalImageConfiguration', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(help_text='Name for this image configuration.', max_length=100)), + ('max_images', models.IntegerField(default=1, help_text='Maximum number of images allowed.', validators=[django.core.validators.MinValueValidator(0)])), + ('image_size', models.IntegerField(default=5242880, help_text='Maximum image size in bytes (default: 5MB).', validators=[django.core.validators.MinValueValidator(1)])), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical Image Configuration', + 'verbose_name_plural': 'historical Image Configurations', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalVoice', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('type', models.CharField(blank=True, choices=[('SpeechToText', 'Speech To Text'), ('TextToText', 'Text To Text'), ('TextToSpeech', 'Text To Speech'), ('Transliterate', 'Transliteration')], max_length=300, null=True)), + ('provider', models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('GOOGLE_V1', 'GOOGLE v1 STT'), ('AI4Bharat', 'AI4Bharat'), ('OPENAI_WHISPER', 'OpenAI Whisper'), ('Sarvam', 'Sarvam')], default='AI4Bharat', max_length=300, null=True)), + ('name', models.CharField(blank=True, max_length=100, null=True)), + ('sample_link', models.URLField(blank=True, null=True)), + ('language', models.CharField(blank=True, max_length=100, null=True)), + ('provider_code', models.CharField(blank=True, max_length=100, null=True)), + ('gender', models.CharField(choices=[('Male', 'Male'), ('Female', 'Female')], default='Male', max_length=100)), + ('voice_speed', models.FloatField(blank=True, default=1.0, null=True, validators=[django.core.validators.MinValueValidator(0.25), django.core.validators.MaxValueValidator(4.0)])), + ('other_params', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('company_bot', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical voice', + 'verbose_name_plural': 'historical voices', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/chatbot/migrations/0076_historicalprofile_latest_flow_profile_latest_flow.py b/chatbot/migrations/0076_historicalprofile_latest_flow_profile_latest_flow.py new file mode 100644 index 0000000..fabb346 --- /dev/null +++ b/chatbot/migrations/0076_historicalprofile_latest_flow_profile_latest_flow.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-12-30 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0075_historicalimageconfiguration_historicalvoice'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprofile', + name='latest_flow', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.flow'), + ), + migrations.AddField( + model_name='profile', + name='latest_flow', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='chatbot.flow'), + ), + ] diff --git a/chatbot/migrations/0077_merge_20260101_0105.py b/chatbot/migrations/0077_merge_20260101_0105.py new file mode 100644 index 0000000..f1fd85c --- /dev/null +++ b/chatbot/migrations/0077_merge_20260101_0105.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-12-31 19:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0070_historicalmedia_thumbnail_media_thumbnail'), + ('chatbot', '0076_historicalprofile_latest_flow_profile_latest_flow'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0078_merge_20260126_0019.py b/chatbot/migrations/0078_merge_20260126_0019.py new file mode 100644 index 0000000..ff118d0 --- /dev/null +++ b/chatbot/migrations/0078_merge_20260126_0019.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2026-01-25 18:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0073_historicalmedia_external_file_id_and_more'), + ('chatbot', '0077_merge_20260101_0105'), + ] + + operations = [ + ] diff --git a/chatbot/migrations/0079_alter_companybot_llm_model_and_more.py b/chatbot/migrations/0079_alter_companybot_llm_model_and_more.py new file mode 100644 index 0000000..3649bac --- /dev/null +++ b/chatbot/migrations/0079_alter_companybot_llm_model_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2 on 2026-03-03 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0078_merge_20260126_0019'), + ] + + operations = [ + migrations.AlterField( + model_name='companybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct'), ('gpt-5.2', 'GPT_5_2'), ('gpt-5.2-pro', 'GPT_5_2_PRO'), ('gpt-5-mini', 'GPT_5_MINI')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='companystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Next Stage')], default='NONE', help_text='Define how to use the postprocess output.', max_length=100), + ), + migrations.AlterField( + model_name='companystatemachine', + name='postprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Postprocess Bot')], default='NONE', help_text="Choose how this stage should be postprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Postprocess Bot' lets you select a separate bot to handle complex logic.", max_length=100), + ), + migrations.AlterField( + model_name='companystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage'), ('MODIFY_QUESTION', 'Modify Bot Question')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=100), + ), + migrations.AlterField( + model_name='companystatemachine', + name='preprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Preprocess Bot')], default='NONE', help_text="Choose how this stage should be preprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Preprocess Bot' lets you select a separate bot to handle complex logic.", max_length=100), + ), + migrations.AlterField( + model_name='flow', + name='create_story', + field=models.CharField(choices=[('all', 'All'), ('none', 'None')], default='all', help_text='Whether to post process the story or not', max_length=20), + ), + migrations.AlterField( + model_name='historicalcompanybot', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct'), ('gpt-5.2', 'GPT_5_2'), ('gpt-5.2-pro', 'GPT_5_2_PRO'), ('gpt-5-mini', 'GPT_5_MINI')], default='gpt-4o-mini', help_text='Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4).', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='postprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip Next Stage')], default='NONE', help_text='Define how to use the postprocess output.', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='postprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Postprocess Bot')], default='NONE', help_text="Choose how this stage should be postprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Postprocess Bot' lets you select a separate bot to handle complex logic.", max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='preprocess_output_mode', + field=models.CharField(choices=[('NONE', 'None'), ('SKIP', 'Skip This Stage'), ('MODIFY_QUESTION', 'Modify Bot Question')], default='NONE', help_text="Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output.", max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanystatemachine', + name='preprocess_type', + field=models.CharField(choices=[('NONE', 'None'), ('SIMPLE', 'Simple Prompt'), ('COMPLEX', 'Use Preprocess Bot')], default='NONE', help_text="Choose how this stage should be preprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Preprocess Bot' lets you select a separate bot to handle complex logic.", max_length=100), + ), + migrations.AlterField( + model_name='historicalflow', + name='create_story', + field=models.CharField(choices=[('all', 'All'), ('none', 'None')], default='all', help_text='Whether to post process the story or not', max_length=20), + ), + migrations.AlterField( + model_name='historicalvoice', + name='provider', + field=models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('GOOGLE_V1', 'GOOGLE v1 STT'), ('AI4Bharat', 'AI4Bharat'), ('OPENAI_WHISPER', 'OpenAI Whisper'), ('Sarvam', 'Sarvam'), ('CUSTOM_LLM', 'Custom LLM')], default='AI4Bharat', max_length=300, null=True), + ), + migrations.AlterField( + model_name='voice', + name='provider', + field=models.CharField(blank=True, choices=[('GOOGLE', 'GOOGLE'), ('GOOGLE_V1', 'GOOGLE v1 STT'), ('AI4Bharat', 'AI4Bharat'), ('OPENAI_WHISPER', 'OpenAI Whisper'), ('Sarvam', 'Sarvam'), ('CUSTOM_LLM', 'Custom LLM')], default='AI4Bharat', max_length=300, null=True), + ), + ] diff --git a/chatbot/migrations/0080_alter_companychat_file_url.py b/chatbot/migrations/0080_alter_companychat_file_url.py new file mode 100644 index 0000000..70c8802 --- /dev/null +++ b/chatbot/migrations/0080_alter_companychat_file_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2026-03-07 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0079_alter_companybot_llm_model_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='companychat', + name='file_url', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/chatbot/migrations/0081_alter_chatsession_session_type.py b/chatbot/migrations/0081_alter_chatsession_session_type.py new file mode 100644 index 0000000..79c7978 --- /dev/null +++ b/chatbot/migrations/0081_alter_chatsession_session_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2026-03-10 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0080_alter_companychat_file_url'), + ] + + operations = [ + migrations.AlterField( + model_name='chatsession', + name='session_type', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/chatbot/migrations/0082_tag_is_theme_tag_icon.py b/chatbot/migrations/0082_tag_is_theme_tag_icon.py new file mode 100644 index 0000000..097c875 --- /dev/null +++ b/chatbot/migrations/0082_tag_is_theme_tag_icon.py @@ -0,0 +1,23 @@ +# Generated manually for adding theme metadata to Global Tag + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0081_alter_chatsession_session_type'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='is_theme', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='tag', + name='icon', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/chatbot/migrations/__init__.py b/chatbot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/models/__init__.py b/chatbot/models/__init__.py new file mode 100644 index 0000000..a358223 --- /dev/null +++ b/chatbot/models/__init__.py @@ -0,0 +1,10 @@ +from .base_models import * +from .enums import * +from .story_models import * +from .chat_models import * +from .bot_vernacular_model import * +from .media_models import * +from .theme_models import * +from .profile_models import * +from .company_models import * +from .i18n_models import * \ No newline at end of file diff --git a/chatbot/models/auth_models.py b/chatbot/models/auth_models.py new file mode 100644 index 0000000..b09808a --- /dev/null +++ b/chatbot/models/auth_models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class BlacklistedToken(models.Model): + """ + Stores authentication tokens that have been invalidated or revoked. + Used to prevent blacklisted tokens from being reused. + """ + token = models.TextField(unique=True) + blacklisted_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.token diff --git a/chatbot/models/base_models.py b/chatbot/models/base_models.py new file mode 100644 index 0000000..7aeb27a --- /dev/null +++ b/chatbot/models/base_models.py @@ -0,0 +1,12 @@ +# Backward compatibility - re-export models from their new locations +# This allows existing code to continue importing from base_models without breaking +from chatbot.models.company_models import ( + Company, CompanyBot, CompanyChat, Voice, CompanyStateMachine, + ImageConfiguration, Flow +) +from chatbot.models.profile_models import Profile + +__all__ = [ + 'Company', 'CompanyBot', 'CompanyChat', 'Voice', 'CompanyStateMachine', + 'ImageConfiguration', 'Flow', 'Profile' +] diff --git a/chatbot/models/bot_vernacular_model.py b/chatbot/models/bot_vernacular_model.py new file mode 100644 index 0000000..de69a62 --- /dev/null +++ b/chatbot/models/bot_vernacular_model.py @@ -0,0 +1,38 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from chatbot.models import CompanyBot + + +class BotVernacular(models.Model): + """ + Stores language-specific (vernacular) configurations for a company bot. + Allows customized introductory and error messages per language. + """ + + company_bot = models.ForeignKey(CompanyBot, on_delete=models.SET_NULL, related_name='bot_vernacular', null=True) + + language = models.CharField(max_length=250, help_text="Language code, Example for English use en.") + introductory_message = models.TextField( + null=True, blank=True, help_text="Provide an introductory message that the bot will present when the " + "conversation starts." + ) + alt_introductory_message = models.TextField( + null=True, blank=True, help_text="Provide an alternate introductory message that the bot will present when the " + "conversation starts." + ) + name = models.CharField(max_length=100,null=True, blank=True, help_text="Enter the name of the bot.") + error_message = models.TextField( + null=True, blank=True, help_text="Provide an error message that the bot will display." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."bot_vernacular' + indexes = [ + models.Index(fields=['language']), + models.Index(fields=['created_at']), + models.Index(fields=['company_bot']), + ] diff --git a/chatbot/models/chat_models.py b/chatbot/models/chat_models.py new file mode 100644 index 0000000..3d494d0 --- /dev/null +++ b/chatbot/models/chat_models.py @@ -0,0 +1,117 @@ +from django.db import models +from chatbot.models import CompanyChat, Profile, CompanyBot, ChatStatus, LLMModel, Voice, VoiceType, LLMProvider, \ + ChatType, StoryLanguageChoices +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.utils.audio_provider_utils import text_translate_provider +import json_repair + +from chatbot.utils.chat_utils import get_guided_chat + + +class ChatSession(models.Model): + """ + Represents an active chat session between a user profile and a company bot. + Stores session metadata, conversation state, and handles title generation using LLMs. + """ + + session = models.CharField(max_length=255, unique=True) + profile = models.ForeignKey(Profile, on_delete=models.DO_NOTHING, null=True, blank=True) + company_bot = models.ForeignKey(CompanyBot, on_delete=models.SET_NULL, null=True, blank=True) + language = models.CharField(max_length=1000, choices=StoryLanguageChoices.choices, + default=StoryLanguageChoices.ENGLISH) + title = models.CharField(max_length=255, null=True, blank=True) + summary = models.TextField(null=True, blank=True) + current_step = models.IntegerField(null=True, blank=True) + session_context = models.JSONField(null=True, blank=True) + session_status = models.CharField(max_length=20, choices=ChatStatus.choices, null=True, blank=True) + project_id = models.CharField(max_length=400, null=True, blank=True) + user_id = models.CharField(max_length=400, null=True, blank=True) + session_type = models.CharField(max_length=255, null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save_title(self, language='en'): + company_chats = CompanyChat.objects.select_related('sender', 'receiver').filter(session=self.session).order_by('created_at').values("receiver", "receiver__id", "translated_message", "message", "status", "created_at") + if self.profile: + company_bot = CompanyBot.objects.filter(company=self.profile.company, route='/mohini_title').first() + else: + company_bot = CompanyBot.objects.filter(route='/mohini_title').first() + + if not company_bot: + return + + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats + ) + prompt = self._get_prompt(company_bot=company_bot) + + json_output = self._handle_llm_model( + prompt=prompt, messages=messages, company_bot=company_bot + ) + try: + if isinstance(json_output, str): + json_output = json_repair.repair_json(json_output, return_objects=True) + output_title = json_output.get('title') + except Exception as e: + print("Error: ", e) + output_title = 'MI Story' + if language != 'en': + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + response = text_translate_provider( + voice_provider=voice_provider, message_body=output_title, target_language=language, + source_language='en' + ) + if response.get('status') == 200: + output_title = response.get('content') + + self.title = output_title + self.save() + + def _get_prompt(self, company_bot): + prompt = company_bot.context + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': prompt}] + elif company_bot.provider == LLMProvider.OPENAI: + return [ + { + 'role': 'system', + 'content': prompt + }, + ] + + def _handle_llm_model(self, prompt, messages, company_bot): + response_json = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + response_json = handle_bedrock_model( + system_prompt=prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tool, company_bot=company_bot + ) + elif company_bot.provider == LLMProvider.OPENAI: + response_json = handle_openai_model( + system_prompt=prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token + ) + + if response_json and isinstance(response_json, dict): + if response_json.get('parameters'): + response_json = response_json.get('parameters') + elif response_json.get('input'): + response_json = response_json.get('input') + return response_json + + def _parse_response(self, response): + response_str = str(response.content, encoding="utf-8") + response_json = json_repair.repair_json(response_str, return_objects=True) + response_content = response_json['choices'][0]['message']['content'] + cleaned_content = (response_content.replace('\n', '').replace('\t', '').replace('\r', '') + .replace('\\n', '').replace('\\t', '').replace('\\r', '')) + return json_repair.repair_json(cleaned_content, return_objects=True) diff --git a/chatbot/models/company_models.py b/chatbot/models/company_models.py new file mode 100644 index 0000000..bcfdeab --- /dev/null +++ b/chatbot/models/company_models.py @@ -0,0 +1,645 @@ +import os +from copy import deepcopy + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.core.validators import MinValueValidator, MaxValueValidator +from simple_history.models import HistoricalRecords + +from chatbot.constants.voice_provider_defaults import get_provider_defaults, VOICE_PROVIDER_DEFAULTS +from chatbot.models.enums import ( + CreateStoryChoices, EntityStatus, LLMModel, GenderChoices, ChatStatus, + FeedbackChoices, CompanyBotTypeChoices, CompanyBotDynamicContextType, CompanyChatSourceChoices, + VoiceProvider, VoiceType, LLMProvider, EntityTypeChoices, TextConversionType, + PreProcessType, PreProcessOutputMode, PostProcessType, PostProcessOutputMode, + UserTypeChoices, OperationTypeChoices, BotStrategyChoices +) + +S3_BASE_URL = os.getenv('S3_BASE_URL') + + +class Company(models.Model): + """ + Represents a company that owns and manages chatbot configurations. + Stores company details like name, slug, status, and logo. + """ + + def get_file_upload_path(self, filename): + folder_name = 'chatbot/company/{}'.format(self.slug) + upload_path = f"{folder_name}/{filename}" + return upload_path + + name = models.CharField(max_length=100) + slug = models.CharField(max_length=100, unique=True) + status = models.CharField(max_length=20, choices=EntityStatus.choices) + url = models.URLField(blank=True, null=True) + logo = models.ImageField(upload_to=get_file_upload_path, max_length=1000, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class Meta: + indexes = [ + models.Index(fields=['slug']), + ] + + def get_public_url(self): + return f"{S3_BASE_URL}{self.logo.name}" + + +class CompanyBot(models.Model): + """ + Defines a chatbot configuration for a specific company. + Stores LLM settings, prompts, provider details, and behavior controls. + """ + + def get_file_upload_path(self, filename): + folder_name = self.company.slug+'/'+'static-media' + upload_path = f"{folder_name}/{filename}" + return upload_path + + name = models.CharField(max_length=100, help_text="Enter the name of the bot.") + company = models.ForeignKey( + Company, on_delete=models.CASCADE, help_text="Select the company this bot belongs to." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + context = models.TextField(help_text="Provide the bot's main prompt or description of its purpose.") + max_token = models.IntegerField(default=2048, validators=[MinValueValidator(1)]) + provider = models.CharField( + max_length=100, choices=LLMProvider.choices, default=LLMProvider.OPENAI) + provider_keys = models.TextField( + default="", max_length=1000, null=False, blank=True) + bot_temperature = models.FloatField( + default=0, + help_text="Set the temperature for controlling response randomness (0-1). Lower values produce more " + "deterministic responses." + ) + top_k = models.IntegerField( + default=2, validators=[MinValueValidator(1)], + help_text="Set the top-k value for the bot's response selection. This defines how many top options to consider " + "for each response." + ) + provider = models.CharField( + max_length=100, choices=LLMProvider.choices, default=LLMProvider.BEDROCK_CONVERSE, + help_text="Select the LLM provider (BEDROCK, BEDROCK_CONVERSE, or OPENAI)" + ) + provider_keys = models.TextField( + default="", max_length=1000, null=False, blank=True, + help_text="API keys or credentials for the selected LLM provider." + ) + llm_model = models.CharField( + max_length=100, choices=LLMModel.choices, default=LLMModel.GPT4_O_MINI, + help_text="Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4)." + ) + filter_score = models.FloatField( + default=0.8, + help_text="Set the filter score for bot response selection (0-1). Responses below this score will be " + "filtered out." + ) + end_context = models.TextField( + null=True, blank=True, + help_text="Provide additional prompt or context to append at the end of the main prompt to guide the " + "conversation" + ) + introductory_message = models.CharField( + max_length=1000, null=True, blank=True, + help_text="Provide an introductory message that the bot will present when the conversation starts." + ) + tag_context = models.TextField( + null=True, blank=True, + help_text="Provide any information or context related to variables (like Python-bound variables) that will be " + "inserted into the prompt." + ) + route = models.CharField( + max_length=100, default='/', help_text="Specify the route or API endpoint for interacting with the bot." + ) + bot_type = models.CharField(max_length=30, choices=CompanyBotTypeChoices.choices, + default=CompanyBotTypeChoices.SIMPLE) + strategy = models.CharField( + max_length=100, + choices=BotStrategyChoices.choices, + null=True, + blank=True, + help_text="Select the strategy or approach this bot uses for conversations." + ) + llm_key = models.CharField(max_length=255, null=True, blank=True) + dynamic_context = models.TextField( + null=True, blank=True, + help_text="Provide dynamic context that can be adjusted during the bot's interactions, such as " + "personalized data." + ) + dynamic_context_type = models.CharField(max_length=20, choices=CompanyBotDynamicContextType.choices, + null=True, blank=True) + pre_context = models.TextField( + null=True, blank=True, help_text="Provide pre-context that will be set before the main prompt to shape the " + "conversation." + ) + tool_context = models.TextField(null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + + connect_timeout = models.FloatField(default=5.0, help_text="Timeout in seconds for establishing a LLM connection.") + read_timeout = models.FloatField(default=10.0, help_text="Timeout in seconds for reading a LLM response.") + chat_history_limit = models.IntegerField( + default=1000, validators=[MinValueValidator(1)], + help_text=( + "Controls how many of the most recent chat messages are included " + "as conversation history when making an LLM request." + ) + ) + stream = models.BooleanField( + default=False, + help_text=( + "Enable streaming mode for LLM responses." + ) + ) + + history = HistoricalRecords() + + def __str__(self): + return self.name + + class Meta: + indexes = [ + models.Index(fields=['company']), + ] + + +class CompanyChat(models.Model): + """ + Represents a chat message exchanged between a user and a company bot. + Stores message content, session data, metadata, and optional attachments. + """ + + def get_file_upload_path(self, filename): + folder_name = f'chatbot' + upload_path = os.path.join(folder_name, filename) + print("upload_path: ", upload_path) + return upload_path + + message = models.TextField() + translated_message = models.TextField(null=True, blank=True) + chunks = models.TextField(null=True) + sender = models.ForeignKey('chatbot.Profile', related_name='sender', on_delete=models.SET_NULL, null=True) + receiver = models.ForeignKey('chatbot.Profile', related_name='receiver', on_delete=models.SET_NULL, null=True) + session = models.CharField(max_length=255) + created_at = models.DateTimeField() + updated_at = models.DateTimeField(auto_now=True) + status = models.CharField(max_length=20, choices=ChatStatus.choices, null=True, blank=True) + feedback = models.CharField(max_length=20, choices=FeedbackChoices.choices, null=True, blank=True) + source = models.CharField(max_length=20, choices=CompanyChatSourceChoices.choices, + default=CompanyChatSourceChoices.WEB) + source_msg_id = models.CharField(max_length=256, null=True, blank=True) + whatsapp_message_id = models.CharField(max_length=255, null=True, blank=True) + message_type = models.CharField(max_length=20, null=True, blank=True) + stage = models.CharField(max_length=500, null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + audio_file = models.FileField(upload_to=get_file_upload_path, max_length=1000, null=True, blank=True) + file_url = models.TextField(null=True, blank=True) + + def __str__(self): + return self.message + + class Meta: + indexes = [ + models.Index(fields=['session']), + models.Index(fields=['created_at']), + models.Index(fields=['sender']), + models.Index(fields=['receiver']), + ] + + def save(self, *args, **kwargs): + if not self.created_at: + self.created_at = timezone.now() + super(CompanyChat, self).save(*args, **kwargs) + + +class Voice(models.Model): + """ + Defines a text-to-speech voice configuration for a company bot. + Stores provider details, language, gender, and playback settings. + """ + + company_bot = models.ForeignKey(CompanyBot, on_delete=models.SET_NULL, null=True, blank=True) + type = models.CharField(max_length=300, choices=VoiceType.choices, null=True, blank=True) + provider = models.CharField(max_length=300, null=True, blank=True, + choices=VoiceProvider.choices, default=VoiceProvider.AI4Bharat) + name = models.CharField(max_length=100, null=True, blank=True) + sample_link = models.URLField(null=True, blank=True) + language = models.CharField(max_length=100, null=True, blank=True) + provider_code = models.CharField(max_length=100, null=True, blank=True) + gender = models.CharField(max_length=100, choices=GenderChoices.choices, default=GenderChoices.MALE) + voice_speed = models.FloatField( + null=True, blank=True, default=1.0, + validators=[MinValueValidator(0.25), MaxValueValidator(4.0)] + ) + + other_params = models.JSONField(null=True, blank=True) + history = HistoricalRecords() + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.provider}-{self.type}" + + def save(self, *args, **kwargs): + + if self.other_params == "null": + self.other_params = None + + defaults = VOICE_PROVIDER_DEFAULTS.get(self.provider, {}).get(self.type, {}) + + if self.pk: + old = Voice.objects.filter(pk=self.pk).first() + + # If provider or type changed → reset config + if old and (old.provider != self.provider or old.type != self.type): + self.other_params = deepcopy(defaults) + + # If new object and params empty → load defaults + if self.other_params in (None, {}, "null") and defaults: + self.other_params = deepcopy(defaults) + + super().save(*args, **kwargs) + + class Meta: + indexes = [ + models.Index(fields=['company_bot']), + models.Index(fields=['created_at']), + models.Index(fields=['type']), + models.Index(fields=['provider']), + ] + + +class CompanyStateMachine(models.Model): + """ + Represents a step in a structured conversational workflow for a company bot. + Defines stage logic, prompts, and optional pre/post processing rules. + """ + + company_bot = models.ForeignKey(CompanyBot, on_delete=models.CASCADE) + name = models.CharField(max_length=100, help_text="Enter the name of the state.") + step = models.IntegerField( + help_text="Integer representing the order in which state function calling happens. Lower values are " + "called first." + ) + use_stage_chats = models.BooleanField( + default=False, + verbose_name="Use Stage Chats", + help_text="If True, only chats from this stage will be included and passed to the LLM." + ) + type = models.CharField( + max_length=10, choices=EntityTypeChoices.choices, default=EntityTypeChoices.MANDATORY, + help_text="Specify whether the state is mandatory or optional." + ) + text_conversion_type = models.CharField( + max_length=15, choices=TextConversionType.choices, default=TextConversionType.TRANSLATE, + help_text="Choose how to process this field's text: " + "'Translation' converts meaning into another language, " + "'Transliteration' preserves sound using another script." + ) + bot_question = models.TextField( + null=True, blank=True, help_text="Provide the first question that the bot will ask when the state is triggered." + ) + completion_criteria = models.TextField( + null=True, blank=True, + help_text="Define the criteria required to move from this state to the next state." + ) + context = models.TextField( + null=True, blank=True, help_text="Provide the main prompt or description of the state, explaining its purpose." + ) + tool_context = models.TextField(null=True, blank=True) + preprocess_type = models.CharField( + max_length=100, choices=PreProcessType.choices, default=PreProcessType.NONE, + help_text="Choose how this stage should be preprocessed: " + "'Simple Prompt' lets you define a direct prompt, " + "'Use Preprocess Bot' lets you select a separate bot to handle complex logic." + ) + + preprocess_prompt = models.TextField( + blank=True, null=True, + help_text="Define the skip logic prompt if Preprocess Type is SIMPLE. " + ) + + preprocess_bot = models.ForeignKey( + CompanyBot, + on_delete=models.SET_NULL, null=True, blank=True, related_name='preprocess_bots', + help_text="Select which Bot to use for preprocessing for complex logic." + ) + + preprocess_output_mode = models.CharField( + max_length=100, choices=PreProcessOutputMode.choices, default=PreProcessOutputMode.NONE, + help_text="Define how to use the preprocess output: " + "'Skip' means use output to decide if stage should be skipped; " + "'Enrich' means use output in this stage's prompt; " + "'Custom' means run custom logic on the output." + ) + postprocess_type = models.CharField( + max_length=100, choices=PostProcessType.choices, default=PostProcessType.NONE, + help_text="Choose how this stage should be postprocessed: " + "'Simple Prompt' lets you define a direct prompt, " + "'Use Postprocess Bot' lets you select a separate bot to handle complex logic." + ) + + postprocess_prompt = models.TextField( + blank=True, null=True, + help_text="Define the postprocess prompt if Postprocess Type is SIMPLE." + ) + + postprocess_bot = models.ForeignKey( + CompanyBot, + on_delete=models.SET_NULL, null=True, blank=True, related_name='postprocess_bots', + help_text="Select which Bot to use for postprocessing for complex logic." + ) + + postprocess_output_mode = models.CharField( + max_length=100, choices=PostProcessOutputMode.choices, default=PostProcessOutputMode.NONE, + help_text="Define how to use the postprocess output." + ) + + skip_to_step = models.IntegerField( + null=True, blank=True, + help_text="If set, the flow will skip directly to this step number when skip conditions are met." + ) + + operation_type = models.CharField( + max_length=20, + choices=OperationTypeChoices.choices, + default=OperationTypeChoices.LLM, + help_text="Choose whether this state uses LLM or non-LLM processing." + ) + + skip_if_authenticated = models.BooleanField( + default=False, + help_text="If True, this state will be skipped for authenticated users." + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def clean(self): + # --- Preprocess validation --- + if self.preprocess_type == PreProcessType.SIMPLE: + if not self.preprocess_prompt or self.preprocess_prompt.strip() == '': + raise ValidationError({ + 'preprocess_prompt': "Preprocess prompt is required when Preprocess Type is SIMPLE." + }) + if self.preprocess_output_mode == PreProcessOutputMode.NONE: + raise ValidationError({ + 'output_mode': "Output mode cannot be NONE when Preprocess Type is SIMPLE." + }) + + elif self.preprocess_type == PreProcessType.COMPLEX: + if not self.preprocess_bot: + raise ValidationError({ + 'preprocess_bot': "Preprocess bot must be selected for the selected preprocess type." + }) + if self.preprocess_output_mode == PreProcessOutputMode.NONE: + raise ValidationError({ + 'output_mode': "Output mode cannot be NONE for the selected preprocess type." + }) + else: + self.preprocess_prompt = None + self.preprocess_bot = None + self.preprocess_output_mode = PreProcessOutputMode.NONE + + # --- Postprocess validation --- + if self.postprocess_type == PostProcessType.SIMPLE: + if not self.postprocess_prompt or self.postprocess_prompt.strip() == '': + raise ValidationError({ + 'postprocess_prompt': "Postprocess prompt is required when Postprocess Type is SIMPLE." + }) + if self.postprocess_output_mode == PostProcessOutputMode.NONE: + raise ValidationError({ + 'postprocess_output_mode': "Output mode cannot be NONE when Postprocess Type is SIMPLE." + }) + elif self.postprocess_type == PostProcessType.COMPLEX: + if not self.postprocess_bot: + raise ValidationError({ + 'postprocess_bot': "Postprocess bot must be selected for the selected postprocess type." + }) + if self.postprocess_output_mode == PostProcessOutputMode.NONE: + raise ValidationError({ + 'postprocess_output_mode': "Output mode cannot be NONE for the selected postprocess type." + }) + else: + self.postprocess_prompt = None + self.postprocess_bot = None + self.postprocess_output_mode = PostProcessOutputMode.NONE + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.company_bot.name} - {self.name}" + + +class ImageConfiguration(models.Model): + """ + Configuration for image handling in flows and bots. + Defines constraints like max images, size limits, and naming. + """ + name = models.CharField( + max_length=100, + help_text="Name for this image configuration." + ) + max_images = models.IntegerField( + default=1, + validators=[MinValueValidator(0)], + help_text="Maximum number of images allowed." + ) + image_size = models.IntegerField( + default=5242880, # 5MB in bytes + validators=[MinValueValidator(1)], + help_text="Maximum image size in bytes (default: 5MB)." + ) + history = HistoricalRecords() + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.name} (Max: {self.max_images}, Size: {self.image_size} bytes)" + + class Meta: + verbose_name = "Image Configuration" + verbose_name_plural = "Image Configurations" + indexes = [ + models.Index(fields=['name']), + ] + + +class Flow(models.Model): + """ + Flow model representing a conversation flow configuration. + Links to CompanyBot, can have associated State Machines, Voice configurations, and Image settings. + """ + flow_name = models.CharField( + max_length=255, + help_text="Name of the flow." + ) + flow_route = models.CharField( + max_length=255, + help_text="Route/path for accessing this flow.", + unique=True + ) + languages = models.JSONField( + default=["en", "hi", "kn", "te"], + help_text="List of supported language codes (e.g., ['en', 'hi', 'kn'])." + ) + hidden = models.BooleanField( + default=False, + help_text="If True, this flow will be hidden from public listing." + ) + active = models.BooleanField( + default=True, + help_text="If False, this flow will be disabled and not accessible." + ) + bot = models.ForeignKey( + CompanyBot, + on_delete=models.CASCADE, + related_name='flows', + help_text="The main bot associated with this flow." + ) + story_bot = models.ForeignKey( + CompanyBot, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='story_flows', + help_text="Optional secondary bot for story-related functionality." + ) + story_validation_bot = models.ForeignKey( + CompanyBot, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='story_validation_flows', + help_text="Optional secondary bot for story-related functionality." + ) + websocket_url = models.CharField( + max_length=500, + help_text="WebSocket path for real-time communication (e.g., ws/common). Do not include protocol or host.", + default='ws/common/' + ) + parent_flow = models.ForeignKey( + 'self', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='child_flows', + help_text="Parent flow if this is a sub-flow." + ) + user_type = models.CharField( + max_length=20, + choices=UserTypeChoices.choices, + default=UserTypeChoices.ALL, + help_text="User types allowed to access this flow (guest, auth, or all)." + ) + image_config = models.ForeignKey( + ImageConfiguration, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='flows', + help_text="Image configuration settings for this flow." + ) + create_story = models.CharField( + max_length=20, + choices=CreateStoryChoices.choices, + default=CreateStoryChoices.ALL, + help_text="Whether to post process the story or not" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return f"{self.flow_name} ({self.flow_route})" + + class Meta: + verbose_name = "Flow" + verbose_name_plural = "Flows" + indexes = [ + models.Index(fields=['flow_route']), + models.Index(fields=['bot']), + models.Index(fields=['active']), + models.Index(fields=['hidden']), + ] + + def clean(self): + """Validate flow configuration.""" + super().clean() + + # Validate that languages is a list + if not isinstance(self.languages, list): + raise ValidationError({ + 'languages': "Languages must be a list of language codes." + }) + + if len(self.languages) != len(list(set(self.languages))): + raise ValidationError({ + 'languages': "Language codes must be unique." + }) + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + +class PDFTemplates(models.Model): + """ + Model for storing PDF templates used in flows. + Contains template configurations for generating PDFs with dynamic content. + """ + template = models.TextField( + help_text="Template content for PDF generation (e.g., HTML, EJS template)." + ) + template_name = models.CharField( + max_length=255, + unique=True, + help_text="Unique name identifier for this template." + ) + user_type = models.CharField( + max_length=20, + choices=UserTypeChoices.choices, + default=UserTypeChoices.ALL, + help_text="User types that can use this template (guest, auth, or all)." + ) + constants_json = models.JSONField( + null=True, + blank=True, + help_text="JSON object containing constants/variables used in the template." + ) + flow = models.ForeignKey( + Flow, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='pdf_templates', + help_text="Flow associated with this template." + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return f"{self.template_name} ({self.user_type})" + + class Meta: + verbose_name = "PDF Template" + verbose_name_plural = "PDF Templates" + indexes = [ + models.Index(fields=['template_name']), + models.Index(fields=['user_type']), + ] + diff --git a/chatbot/models/enums.py b/chatbot/models/enums.py new file mode 100644 index 0000000..68f1389 --- /dev/null +++ b/chatbot/models/enums.py @@ -0,0 +1,534 @@ +from random import choices + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ChatStatus(models.TextChoices): + """ + Represents the lifecycle status of a chat session. + Used to track conversation progress and state transitions. + """ + STARTED = 'STARTED', _('STARTED') + IN_PROGRESS = 'IN_PROGRESS', _('IN_PROGRESS') + COMPLETED = 'COMPLETED', _('COMPLETED') + PAUSED = 'PAUSED', _('PAUSED') + RESUME = 'RESUME', _('RESUME') + + +class ChatType(models.TextChoices): + """ + Defines supported chat workflow types. + Controls conversation structure and bot behavior. + """ + guidedReflection = 'normal', _('Guided Reflection') + oneStepReflection = 'oneshot', _('One Step Reflection') + shikshaChaupal = 'shikshalokam_chaupal', _('Shiksha Chaupal') + reflection = 'reflection', _('Reflection') + creation = 'creation', _('Creation') + megaPTM = 'megaPTM', _('Mega PTM') + YLC = 'YLC', _('YLC') + listeningActivity = 'listening-activity', _('Listening Activity') + ParentPerceptionSurvey = 'parent_perception_survey', _('Parent Perception Survey') + LCF = 'lcf', _('LCF') + LFA = 'lfa', _('LFA') + FreeFlow = 'free_flow', _('Free-Flow') + + +class LLMProvider(models.TextChoices): + """ + Lists supported Large Language Model providers. + Determines which AI backend service is used. + """ + BEDROCK = 'bedrock', _('BEDROCK') + BEDROCK_CONVERSE = 'bedrock/converse', _('BEDROCK_CONVERSE') + OPENAI = 'openai', _('OPENAI') + + +class ThemeType(models.TextChoices): + """ + Specifies theme source for a bot instance. + Used to select custom or master UI themes. + """ + CUSTOM = 'custom', _('Custom for Bot') + MASTER = 'master', _('Using Master Theme') + + +class LLMModel(models.TextChoices): + """ + Enumerates all supported AI model identifiers. + Used for dynamic model configuration. + """ + GPT4 = 'gpt-4', _('GPT4') + GPT4_1 = 'gpt-4.1', _('GPT4_1') + GPT4_1_MINI = 'gpt-4.1-mini', _('GPT4_1-MINI') + GPT4_128K = 'gpt-4-1106-preview', _('GPT4-128k') + GPT4_TURBO = 'gpt-4-turbo', _('GPT4_TURBO') + LLAMA_3_8B_8192 = 'llama3-8b-8192', _('LLAMA_3_8B_8192') + LLAMA_3_70B_8192 = 'llama3-70b-8192', _('LLAMA_3_70B_8192') + LLAMA_3_1_70B_VERSATILE = 'llama-3.1-70b-versatile', _('LLAMA_3_1_70B_VERSATILE') + LLAMA_3_1_8B_INSTANT = 'llama-3.1-8b-instant', _('LLAMA_3_1_8B_INSTANT') + LLAMA_3_1_70B_INSTRUCT = 'meta.llama3-1-70b-instruct-v1:0', _('LLAMA_3_1_70B_INSTRUCT') + LLAMA_3_1_8B_INSTRUCT = 'meta.llama3-1-8b-instruct-v1:0', _('LLAMA_3_1_8B_INSTRUCT') + LLAMA_3_3_70B_INSTRUCT = 'us.meta.llama3-3-70b-instruct-v1:0', _('LLAMA_3_3_70B_INSTRUCT') + LLAMA_3_3_8B_INSTRUCT = 'us.meta.llama3-3-8b-instruct-v1:0', _('LLAMA_3_3_8B_INSTRUCT') + MIXTRAL_8X70B_32768 = 'mixtral-8x7b-32768', _('MIXTRAL_8X70B_32768') + GPT4_O = 'gpt-4o', _('GPT4_O') + GPT4_O_MINI = 'gpt-4o-mini', _('GPT4_O_MINI') + LLAMA_3_1_8B_OPS = 'meta-llama/Meta-Llama-3.1-8B-Instruct', _('meta-llama/Meta-Llama-3.1-8B-Instruct') + GPT5_2 = 'gpt-5.2', _('GPT_5_2') + GPT5_2_PRO = 'gpt-5.2-pro', _('GPT_5_2_PRO') + GPT5_MINI = 'gpt-5-mini', _('GPT_5_MINI') + + +class EntityStatus(models.TextChoices): + """ + Indicates whether an entity is active or inactive. + Supports soft-deletion and visibility control. + """ + ACTIVE = 'ACTIVE', _('ACTIVE') + INACTIVE = 'INACTIVE', _('INACTIVE') + + +class ProfileType(models.TextChoices): + """ + Defines different user profile roles. + Used for access control and permissions. + """ + USER = 'USER', _('USER') + MODERATOR = 'MODERATOR', _('MODERATOR') + PROSPECT = 'PROSPECT', _('PROSPECT') + + +class FeedbackChoices(models.TextChoices): + """ + Captures feedback sentiment classification. + Used for analytics and rating systems. + """ + POSITIVE = 'POSITIVE', _('POSITIVE') + NEGATIVE = 'NEGATIVE', _('NEGATIVE') + + +class GenderChoices(models.TextChoices): + """ + Stores supported gender options. + Used in user demographic information. + """ + MALE = 'Male', _('Male') + FEMALE = 'Female', _('Female') + + +class LanguageChoices(models.TextChoices): + """ + Lists supported language-region codes. + Used for localization and speech services. + """ + INDIAN_ENGLISH = 'en-IN', _('INDIAN ENGLISH') + INDIAN_HINDI = 'hi-IN', _('INDIAN HINDI') + US_ENGLISH = 'en-US', _('US ENGLISH') + INDIAN_KANNADA = 'kn-IN', _('INDIAN KANNADA') + + +class MediaTypeChoices(models.TextChoices): + """ + Supported MIME types for uploaded media. + Used for validation and content handling. + """ + PDF = 'application/pdf', _('PDF') + TXT = 'text/plain', _('TXT') + CSV = 'text/csv', _('CSV') + JPEG = 'image/jpeg', _('JPEG') + PNG = 'image/png', _('PNG') + SVG = 'image/svg+xml', _('SVG') + WEBP = 'image/webp', _('WEBP') + HEIF = 'image/heif', _('HEIF') + HEIC = 'image/heic', _('HEIC') + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', _('XLSX') + + +class FileTypeChoices(models.TextChoices): + """ + Supported document file types with utility helpers. + Provides MIME, extension, and validation methods. + """ + PDF = 'application/pdf', _('PDF') + DOC = 'application/msword', _('DOC') + DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', _('DOCX') + TXT = 'text/plain', _('TXT') + CSV = 'text/csv', _('CSV') + XLS = 'application/vnd.ms-excel', _('XLS') + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', _('XLSX') + + + @classmethod + def get_extension_mapping(cls): + """ + Returns mapping of MIME types to file extensions. + Used for file naming and validation logic. + """ + return { + cls.PDF: '.pdf', + cls.DOC: '.doc', + cls.DOCX: '.docx', + cls.TXT: '.txt', + cls.CSV: '.csv', + cls.XLS: '.xls', + cls.XLSX: '.xlsx', + } + + @classmethod + def get_mime_from_extension(cls, extension): + """ + Converts file extension into MIME type. + Returns None if extension is unsupported. + """ + ext = extension.lower().lstrip('.') + ext_to_mime = { + 'pdf': cls.PDF, + 'doc': cls.DOC, + 'docx': cls.DOCX, + 'txt': cls.TXT, + 'csv': cls.CSV, + 'xls': cls.XLS, + 'xlsx': cls.XLSX, + } + return ext_to_mime.get(ext) + + @classmethod + def get_label_from_extension(cls, extension): + """ + Returns display label from file extension. + Defaults to TXT label if unknown. + """ + mime_type = cls.get_mime_from_extension(extension) + if mime_type: + return cls(mime_type).label + return cls.TXT.label + + @classmethod + def get_valid_extensions(cls): + """ + Returns list of supported file extensions. + Used for upload validation checks. + """ + extension_mapping = cls.get_extension_mapping() + return [ext.lstrip('.') for ext in extension_mapping.values()] + + @classmethod + def is_valid_extension(cls, extension): + """ + Validates if provided extension is supported. + Returns True if valid, otherwise False. + """ + ext = extension.lower().lstrip('.') + return ext in cls.get_valid_extensions() + + +class VoiceProviderChoices(models.TextChoices): + """ + Lists supported cloud voice providers. + Used for speech-to-text and text-to-speech services. + """ + AWS = 'aws', _('AWS') + GCP = 'gcp', _('GCP') + AZURE = 'azure', _('Azure') + ELEVEN_LABS = 'eleven-labs', _('Eleven Labs') + + + +class ChatStageChoices(models.TextChoices): + """ + Represents predefined conversational stages in structured chat flows. + Used in state-machine based bots to control progression. + """ + WELCOME = 'Welcome_Strand', _('WELCOME_STRAND') + ACHIEVEMENT_ORIENTATION = 'Achievement_Orientation', _('ACHIEVEMENT_ORIENTATION') + COURAGE = 'Courage_Strand', _('COURAGE_STRAND') + CONTINUOUS_LEARNING = 'Continuous_Strand', _('CONTINUOUS_STRAND') + CRITICAL_THINKING = 'Critical_Thinking_Strand', _('CRITICAL_THINKING_STRAND') + PURPOSE = 'Purpose_Strand', _('PURPOSE_STRAND') + THANKYOU = 'Thank_You_Strand', _('THANK_YOU_STRAND') + OTHER = 'Other', _('OTHER') + + +class TagChoices(models.TextChoices): + """ + Defines moderation status for tags. + Used in approval and publishing workflows. + """ + APPROVED = 'Approved', _('Approved') + PENDING = 'Pending', _('Pending') + + +class TagSourceChoices(models.TextChoices): + """ + Identifies origin of a tag entry. + Distinguishes manual and AI-based tagging. + """ + MANUAL = 'MANUAL', _('Manual') + AI_EXTRACTED = 'AI_EXTRACTED', _('AI Extracted') + AI_GENERATED = 'AI_GENERATED', _('AI Generated') + + +class StoryLanguageChoices(models.TextChoices): + """ + Lists supported languages for stories. + Used for multilingual story management. + """ + ENGLISH = 'en', _('English') + HINDI = 'hi', _('Hindi') + KANNADA = 'kn', _('Kannada') + TELUGU = 'te', _('Telugu') + ODIA = 'or', _('Odia') + + +class StorySourceChoices(models.TextChoices): + """ + Specifies origin of story content. + Tracks AI, user, or third-party sources. + """ + AI_GENERATED = 'AI_GENERATED', _('AI_GENERATED') + USER_GENERATED = 'USER_GENERATED', _('USER_GENERATED') + THIRD_PARTY = 'THIRD_PARTY', _('THIRD_PARTY') + + +class StoryStatusChoices(models.TextChoices): + """ + Represents lifecycle state of a story. + Used to track processing and completion status. + """ + PENDING = 'PENDING', _('PENDING') + COMPLETED = 'COMPLETED', _('COMPLETED') + + +class EntityTypeChoices(models.TextChoices): + """ + Marks whether an entity is mandatory or optional. + Used in dynamic validation and schema enforcement. + """ + MANDATORY = 'MANDATORY', _('MANDATORY') + OPTIONAL = 'OPTIONAL', _('OPTIONAL') + + +class CompanyBotTypeChoices(models.TextChoices): + """ + Defines architecture type of company bots. + Determines conversation execution strategy. + """ + SIMPLE = 'SIMPLE', _('SIMPLE') + STATE_MACHINE = 'STATE_MACHINE', _('STATE_MACHINE') + DATABASE_SIMPLE = 'DATABASE_SIMPLE', _('DATABASE_SIMPLE') + INTERVIEW_STATE_MACHINE = 'INTERVIEW_STATE_MACHINE', _('INTERVIEW_STATE_MACHINE') + + +class BotStrategyChoices(models.TextChoices): + ONESHOT = 'oneshot', _('One Shot') + GUIDED_GUEST = 'guided_guest', _('Guided Guest') + GUEST_DISCUSSION = 'guest_discussion', _('Guest Discussion') + COMMON = 'common', _('Common') + + +class CompanyBotDynamicContextType(models.TextChoices): + """ + Specifies dynamic context generation mechanism. + Supports SQL queries or Python scripts. + """ + SQL_QUERY = 'SQL_QUERY', _('SQL_QUERY') + PYTHON_SCRIPT = 'PYTHON_SCRIPT', _('PYTHON_SCRIPT') + + +class CompanyChatSourceChoices(models.TextChoices): + """ + Identifies source platform of a chat session. + Used for analytics and usage tracking. + """ + WEB = 'WEB', _('WEB') + PHONE = 'PHONE', _('PHONE') + + +class RouteLanguageChoices(models.TextChoices): + """ + Maps URL route prefixes to language codes. + Used for multilingual routing configuration. + """ + ENGLISH = 'en', _('/') + HINDI = 'hi', _('/hindi') + KANNADA = 'kn', _('/kannada') + TELUGU = 'te', _('/telugu') + ODIA = 'or', _('/odia') + + +class VoiceProvider(models.TextChoices): + """ + Lists supported speech processing providers. + Used for transcription and voice synthesis services. + """ + GOOGLE = 'GOOGLE', _('GOOGLE') + AI4Bharat = 'AI4Bharat', _('AI4Bharat') + OPENAI_WHISPER = 'OPENAI_WHISPER', _('OpenAI Whisper') + SARVAM = 'Sarvam', _('Sarvam') + CUSTOM_LLM = 'CUSTOM_LLM', _('Custom LLM') + + +class VoiceType(models.TextChoices): + """ + Defines type of voice processing operation. + Covers STT, TTS, and transliteration modes. + """ + SpeechToText = 'SpeechToText', _('Speech To Text') + TextToText = 'TextToText', _('Text To Text') + TextToSpeech = 'TextToSpeech', _('Text To Speech') + Transliterate = 'Transliterate', _('Transliteration') + + +class LanguageMapping: + """ + Utility class for region-based language mapping. + Provides fallback if mapping is unavailable. + """ + MAPPING = { + "en": {"IN": "en-IN", "US": "en-US"}, + "hi": {"IN": "hi-IN"}, + "kn": {"IN": "kn-IN"}, + "te": {"IN": "te-IN"}, + "or": {"IN": "or-IN"}, + } + + @classmethod + def get_mapped_language(cls, language_code: str, region: str = "IN") -> str: + """ + Returns region-specific language code mapping. + Defaults to '-IN' if not found. + """ + return cls.MAPPING.get(language_code, {}).get(region, f"{language_code}-IN") + + @classmethod + def get_google_translate_language(cls, language_code: str, region: str = "IN") -> str: + mapped = cls.get_mapped_language(language_code, region) + return mapped.split("-")[0] if "-" in mapped else mapped + + @classmethod + def get_sarvam_language(cls, language_code: str, region: str = "IN") -> str: + normalized = (language_code or "").lower() + if normalized in {"or", "od", "odia"}: + return "od-IN" + return cls.get_mapped_language(language_code, region) + + +class MediaTemplateChoices(models.TextChoices): + """ + Defines supported media template formats. + Used in content rendering workflows. + """ + EJS = 'EJS', _('EJS') + RAW_TEXT = 'RAW-TEXT', _('RAW-TEXT') + + +class PDFStrategyChoices(models.TextChoices): + """ + Lists available PDF generation strategies. + Determines rendering engine implementation. + """ + HTMLPDF = 'HTMLPDF', _('HTMLPDF') + PUPPETEER = 'PUPPETEER', _('PUPPETEER') + HTMLDOCX = 'HTMLDOCX', _('HTMLDOCX') + XLSX = 'XLSX', _('XLSX') + + +class SessionFlowName(models.TextChoices): + """ + Represents predefined session flow identifiers. + Used to control guest, login, and special flows. + """ + GuestDiscussion = 'guest-discussion', _('guest-discussion') + LoginDiscussion = 'login-discussion', _('login-discussion') + GuestMiStory = 'guest-mi-story', _('guest-mi-story') + SchoolSurvey = 'school-survey', _('school-survey') + ListeningActivity = 'listening-activity', _('Listening Activity') + LoginMiStory = 'login', _('login') + SsoFlow = 'sso', _('sso') + Reflection = 'reflection', _('reflection') + megaPTM = 'megaPTM', _('Mega PTM') + YLC = 'YLC', _('YLC') + ParentPerceptionSurvey = 'parent_perception_survey', _('Parent Perception Survey') + creation = 'creation', _('Creation') + + +class PreProcessType(models.TextChoices): + """ + Defines preprocessing strategy before LLM execution. + Controls prompt transformation complexity. + """ + NONE = 'NONE', _('None') + SIMPLE = 'SIMPLE', _('Simple Prompt') + COMPLEX = 'COMPLEX', _('Use Preprocess Bot') + + +class PreProcessOutputMode(models.TextChoices): + """ + Controls behavior after preprocessing stage. + Can skip execution of the current stage. + """ + NONE = 'NONE', 'None' + SKIP = 'SKIP', 'Skip This Stage' + MODIFY_QUESTION = 'MODIFY_QUESTION', 'Modify Bot Question' + + +class PostProcessType(models.TextChoices): + """ + Defines postprocessing strategy after LLM response. + Used for response refinement and enhancement. + """ + NONE = 'NONE', _('None') + SIMPLE = 'SIMPLE', _('Simple Prompt') + COMPLEX = 'COMPLEX', _('Use Postprocess Bot') + + +class PostProcessOutputMode(models.TextChoices): + """ + Controls workflow behavior after postprocessing. + Can skip execution of the next stage. + """ + NONE = 'NONE', 'None' + SKIP = 'SKIP', 'Skip Next Stage' + + +class TextConversionType(models.TextChoices): + """ + Specifies text transformation operation type. + Supports translation and transliteration modes. + """ + TRANSLATE = 'TRANSLATE', _('Translation') + TRANSLITERATE = 'TRANSLITERATE', _('Transliteration') + + +class FileDisplayMode(models.TextChoices): + """ + Controls file visibility scope and permissions. + Determines access for UI and AI processing. + """ + VISIBLE = "visible", _("Visible to All") + AI_ONLY = "ai_only", _("AI Only (Hidden from UI)") + PRIVATE = "private", _("Private (Hidden from UI and AI)") + + +class UserTypeChoices(models.TextChoices): + GUEST = 'guest', _('Guest') + AUTH = 'auth', _('Authenticated') + ALL = 'all', _('All') + + +class CreateStoryChoices(models.TextChoices): + ALL = 'all', _('All') + NONE = 'none', _('None') + + +class LanguageOperationChoices(models.TextChoices): + TRANSLATE = 'translate', _('Translate') + TRANSLITERATE = 'transliterate', _('Transliterate') + + +class OperationTypeChoices(models.TextChoices): + LLM = 'llm', _('LLM') + NON_LLM = 'non_llm', _('Non-LLM') + diff --git a/chatbot/models/geo_models.py b/chatbot/models/geo_models.py new file mode 100644 index 0000000..97f1814 --- /dev/null +++ b/chatbot/models/geo_models.py @@ -0,0 +1,29 @@ +from django.db import models +from django.db.models import DecimalField +from chatbot.models import Profile + + +class ProfileAddress(models.Model): + """ + Stores address and geolocation details associated with a user profile. + Includes full address fields along with optional latitude and longitude. + """ + + profile = models.ForeignKey(Profile, related_name='profile_address', on_delete=models.CASCADE) + address_line_1 = models.CharField(max_length=1000, null=True, blank=True) + address_line_2 = models.CharField(max_length=1000, null=True, blank=True) + block = models.CharField(max_length=1000, null=True, blank=True) + city = models.CharField(max_length=1000, null=True, blank=True) + district = models.CharField(max_length=1000, null=True, blank=True) + state = models.CharField(max_length=1000, null=True, blank=True) + country = models.CharField(max_length=1000, null=True, blank=True) + pincode = models.CharField(null=True, blank=True, max_length=10) + + latitude = DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.profile.first_name diff --git a/chatbot/models/i18n_models.py b/chatbot/models/i18n_models.py new file mode 100644 index 0000000..c76410c --- /dev/null +++ b/chatbot/models/i18n_models.py @@ -0,0 +1,90 @@ +from django.db import models +from django.core.exceptions import ValidationError +from simple_history.models import HistoricalRecords + + +class I18nTag(models.Model): + """ + Model for storing internationalization tag names. + Tags are used to group related translations together. + """ + tag_name = models.CharField( + max_length=255, + unique=True, + help_text="Unique tag name for grouping translations (e.g., 'welcome_message', 'button_labels')." + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return self.tag_name + + class Meta: + verbose_name = "I18n Tag" + verbose_name_plural = "I18n Tags" + indexes = [ + models.Index(fields=['tag_name']), + ] + ordering = ['tag_name'] + + +class I18nTranslation(models.Model): + """ + Model for storing internationalization translations. + Each translation is associated with a tag and can have multiple variables in different languages. + """ + tag_id = models.ForeignKey( + I18nTag, + on_delete=models.CASCADE, + related_name='translations', + help_text="The tag this translation belongs to." + ) + variable_name = models.CharField( + max_length=255, + help_text="Variable name for this translation (e.g., 'title', 'description', 'button_text')." + ) + language = models.CharField( + max_length=10, + help_text="Language code (e.g., 'en', 'hi', 'kn', 'te')." + ) + value = models.TextField( + help_text="Translated text value for this variable in the specified language." + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return f"{self.tag_id.tag_name} - {self.variable_name} ({self.language})" + + class Meta: + verbose_name = "I18n Translation" + verbose_name_plural = "I18n Translations" + indexes = [ + models.Index(fields=['tag_id', 'variable_name', 'language']), + models.Index(fields=['language']), + models.Index(fields=['variable_name']), + ] + unique_together = [['tag_id', 'variable_name', 'language']] + ordering = ['tag_id', 'variable_name', 'language'] + + def clean(self): + """Validate the translation data.""" + super().clean() + + # Validate language code format (should be lowercase) + if self.language: + self.language = self.language.lower().strip() + + # Validate that value is not empty + if not self.value or not self.value.strip(): + raise ValidationError({ + 'value': "Translation value cannot be empty." + }) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/chatbot/models/media_models.py b/chatbot/models/media_models.py new file mode 100644 index 0000000..823a701 --- /dev/null +++ b/chatbot/models/media_models.py @@ -0,0 +1,288 @@ +import os +import base64 +from django.db import models +from chatbot.models import Profile, CompanyBot, MediaTemplateChoices, PDFStrategyChoices, Tag, \ + FileTypeChoices, Company, MediaTypeChoices, FileDisplayMode +from shikshalokam.models.enums import PriorityChoices +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVector, TrigramSimilarity +from simple_history.models import HistoricalRecords +from chatbot.celery_tasks.knowledge_service.media_tasks import save_in_vector_db +import time +from django.utils.text import slugify +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +S3_BASE_URL = os.getenv('S3_MEDIA_URL') + + +class ProfileMedia(models.Model): + """ + Stores media files uploaded by a user profile. + Encodes files to base64 and provides public S3 access. + """ + + def get_file_upload_path(self, filename): + folder_name = 'chatbot/profilemedia/{}'.format(self.profile.id) + upload_path = f"{folder_name}/{filename}" + return upload_path + + profile = models.ForeignKey(Profile, on_delete=models.CASCADE) + file = models.FileField(upload_to=get_file_upload_path, max_length=1000) + base64_str = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def get_public_url(self): + # Assuming your S3 bucket is public, you can directly construct the URL + return f"{S3_BASE_URL}{self.file.name}" + + def save(self, *args, **kwargs): + self.base64_str = base64.b64encode(self.file.read()).decode('utf-8') + super().save(*args, **kwargs) + + +class Media(models.Model): + """ + Represents knowledge/media files linked to a company bot. + Handles storage, preview generation, vector indexing, and similarity search. + """ + + def _get_org_slug(self): + return self.organization.slug if self.organization else self.company_bot.company.slug + + def _generate_clean_filename(self, filename): + timestamp = int(time.time()) + base_name, ext = os.path.splitext(filename) + safe_name = slugify(base_name)[:40] + return f"{timestamp}_{safe_name}{ext.lower()}" + + def get_file_upload_path(self, filename): + clean_filename = self._generate_clean_filename(filename) + org_slug = self._get_org_slug() + + return f'shikshalokam/media/{org_slug}/{clean_filename}' + + def get_thumbnail_upload_path(self, filename): + clean_filename = self._generate_clean_filename(filename) + org_slug = self._get_org_slug() + + return f'shikshalokam/media/{org_slug}/thumbnails/{clean_filename}' + + def save(self, *args, company_slug=None, **kwargs): + is_new = self.pk is None + file_changed = False + + # Check if file has changed (for updates) + if not is_new and self.pk: + try: + old_instance = Media.objects.get(pk=self.pk) + file_changed = old_instance.file != self.file + except Media.DoesNotExist: + pass + + super().save(*args, **kwargs) + + if is_new or file_changed: + from chatbot.celery_tasks.knowledge_service.media_tasks import generate_media_preview + # Schedule preview generation with a small delay + generate_media_preview.apply_async(args=(self.id,), countdown=2) + + if is_new: + task = save_in_vector_db.apply_async(args=(self.id, company_slug), countdown=1) + return task.id + else: + from chatbot.celery_tasks.knowledge_service.media_tasks import delete_from_vector_db + try: + result = delete_from_vector_db(self.id) + status_code = result if isinstance(result, int) else 200 + if status_code != 200: + raise Exception(f"Vector DB deletion failed with status {status_code}") + + print(f"Vector DB deletion status for media_id {self.id}: {status_code}") + task = save_in_vector_db.apply_async(args=(self.id, company_slug), countdown=1) + from celery.result import AsyncResult + return task.id + except Exception as e: + print(f"Error deleting from vector DB for media_id {self.id}: {str(e)}") + status_code = 500 + # task = update_in_vector_db.apply_async(args=(self.id, company_slug), countdown=1) + return + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + + @classmethod + def find_trigram_similar( + cls, extracted_text, company_slug, similarity_threshold=0.85, exclude_id=None + ): + """ + Find media with similar text using trigram similarity (local check) + """ + if not extracted_text or len(extracted_text.strip()) < 50: + return [] + + text_sample = extracted_text[:1500].strip() + + company = Company.objects.get(slug=company_slug) + queryset = cls.objects.filter(company_bot__company=company) + + if exclude_id: + queryset = queryset.exclude(id=exclude_id) + + similar_media = ( + queryset + .annotate( + similarity=TrigramSimilarity('extracted_text', text_sample) + ) + .filter(similarity__gte=similarity_threshold) + .order_by('-similarity') + .values('id', 'name', 'similarity', 'created_at', 'file')[:5] + ) + + return list(similar_media) + + def get_s3_url(self): + return f"{S3_BASE_URL}{self.file.name}" + + def get_thumbnail_s3_url(self): + if not self.thumbnail: + return None + return f"{S3_BASE_URL}{self.thumbnail.name}" + + name = models.CharField(max_length=1000) + organization = models.ForeignKey(Company, on_delete=models.CASCADE, null=True, blank=True) + url = models.URLField(max_length=1000, null=True, blank=True) + priority = models.CharField(max_length=50, default=PriorityChoices.P1, choices=PriorityChoices.choices) + media_type = models.CharField(max_length=100, choices=FileTypeChoices.choices, default=FileTypeChoices.TXT) + company_bot = models.ForeignKey(CompanyBot, on_delete=models.DO_NOTHING) + file = models.FileField(upload_to=get_file_upload_path, max_length=1000) + markdown_file = models.FileField(upload_to=get_file_upload_path, max_length=1000, null=True, blank=True) + description = models.TextField(null=True, blank=True) + extracted_text = models.TextField(null=True, blank=True) + tags = models.ManyToManyField(Tag, related_name="medias") + external_file_id = models.CharField( + max_length=300, null=True, blank=True, + help_text=( + "External provider file identifier used for vector indexing " + "(e.g. OpenAI Files API file_id)" + ) + ) + parent = models.ForeignKey( + 'self', on_delete=models.CASCADE, null=True, blank=True, related_name='subdocuments' + ) + display_mode = models.CharField( + max_length=20, choices=FileDisplayMode.choices, default=FileDisplayMode.VISIBLE + ) + view_count = models.PositiveBigIntegerField(default=0) + download_count = models.PositiveBigIntegerField(default=0) + thumbnail = models.ImageField( + upload_to=get_thumbnail_upload_path, max_length=1000, null=True, blank=True, + help_text="Auto-generated preview thumbnail" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return self.name + + class Meta: + indexes = [ + GinIndex( + SearchVector('extracted_text', config='english'), + name='media_extracted_text_gin' + ), + GinIndex( + fields=['extracted_text'], name='media_extracted_text_trgm', + opclasses=['gin_trgm_ops'], + ) + ] + + +class MediaImage(models.Model): + """ + Stores images extracted or associated with a Media document. + Maintains ordering and metadata like page number and dimensions. + """ + + def get_file_upload_path(self, filename): + folder_name = f'shikshalokam/media/{self.media.company_bot.id}/images' + upload_path = f"{folder_name}/{filename}" + return upload_path + + name = models.CharField(max_length=1000) + file = models.FileField(upload_to=get_file_upload_path, max_length=1000, null=True, blank=True) + media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name='images') + page = models.IntegerField(null=True, blank=True) + index = models.IntegerField(default=0) + width = models.IntegerField(null=True, blank=True) + height = models.IntegerField(null=True, blank=True) + media_type = models.CharField(max_length=100, choices=MediaTypeChoices.choices, null=True, blank=True) + base64_str = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['page', 'index'] + + def __str__(self): + return f"Image {self.index} for {self.media.name}" + + +class MediaVector(models.Model): + """ + Stores vector database reference IDs for a Media document. + Used for semantic search and embedding-based retrieval. + """ + + media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name='media_vector') + vector_id = models.CharField(max_length=1000, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class KeyValue(models.Model): + """ + Stores structured key-value metadata associated with a Media document. + Used for tagging or storing extracted attributes. + """ + + media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name='key_values') + key = models.CharField(max_length=1000) + value = models.TextField(null=True, blank=True) + + def __str__(self): + return f"{self.key}: {self.value}" + + +class MediaTemplate(models.Model): + """ + Defines reusable templates for processing or rendering Media content. + Supports different template types and PDF handling strategies. + """ + + name = models.CharField(max_length=100, null=True, unique=True) + template_content = models.TextField(null=True) + template_type = models.CharField(choices=MediaTemplateChoices.choices, max_length=100, null=True) + pdf_strategy = models.CharField(choices=PDFStrategyChoices.choices, max_length=100, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + +@receiver(pre_delete, sender=Media) +def delete_media_from_vector_db_signal(sender, instance, **kwargs): + from chatbot.celery_tasks.knowledge_service.media_tasks import delete_from_vector_db + + try: + result = delete_from_vector_db(instance.id) + status_code = result if isinstance(result, int) else 200 + print(f"Vector DB deletion status for media_id {instance.id}: {status_code}") + except Exception as e: + print(f"Error deleting from vector DB for media_id {instance.id}: {str(e)}") diff --git a/chatbot/models/profile_models.py b/chatbot/models/profile_models.py new file mode 100644 index 0000000..181e046 --- /dev/null +++ b/chatbot/models/profile_models.py @@ -0,0 +1,72 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.contrib.auth.hashers import make_password +from simple_history.models import HistoricalRecords +from chatbot.models.enums import ( + EntityStatus, GenderChoices, ProfileType, SessionFlowName +) +from chatbot.models.company_models import Company, Flow + + +class Profile(models.Model): + """ + Represents a user profile associated with a company. + Stores personal details, authentication data, and metadata for chatbot interactions. + """ + + def get_file_upload_path(self, filename): + folder_name = self.company.slug+'/'+'profile'+'/'+self.email + upload_path = f"{folder_name}/{filename}" + return upload_path + + first_name = models.CharField(max_length=100, null=True, blank=True) + userid = models.CharField(max_length=200, null=True, blank=True) + last_name = models.CharField(max_length=100, null=True, blank=True) + email = models.EmailField(max_length=1000, null=False, blank=False) + phone = models.CharField(max_length=20, null=True, blank=True) + alternate_phone = models.CharField(max_length=20, null=True, blank=True) + country = models.CharField(max_length=100, null=True, blank=True) + status = models.CharField(max_length=20, choices=EntityStatus.choices, default=EntityStatus.ACTIVE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + company = models.ForeignKey(Company, on_delete=models.DO_NOTHING, null=False, blank=False) + password = models.CharField(max_length=1000, null=True, blank=True) + profile_type = models.CharField(max_length=20, choices=ProfileType.choices, default=ProfileType.USER) + profile_code = models.CharField(max_length=100, null=True, blank=True) + location = models.CharField(max_length=1000, null=True, blank=True) + caste = models.CharField(max_length=1000, null=True, blank=True) + gender = models.CharField(max_length=1000, null=True, blank=True, choices=GenderChoices.choices) + designation = models.TextField(null=True, blank=True) + org_associated = models.CharField(max_length=1000, null=True, blank=True) + product_interested = models.CharField(max_length=1000, null=True, blank=True) + company_spoc = models.CharField(max_length=1000, null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + source = models.CharField(max_length=1000, null=True, blank=True) + preferred_route = models.CharField(max_length=1000, null=True, blank=True) + latest_flow_used = models.CharField(max_length=500, choices=SessionFlowName.choices, null=True, blank=True) + latest_flow = models.ForeignKey(Flow, on_delete=models.DO_NOTHING, null=True, blank=True) + history = HistoricalRecords() + + def __str__(self): + return self.first_name if self.first_name else "" + + def clean(self): + super().clean() + + if self.phone: + if Profile.objects.filter(phone=self.phone, company=self.company).exists(): + raise ValidationError({ + 'phone': 'A profile with this phone number already exists for the specified company.' + }) + + def save(self, *args, **kwargs): + if self.password and 'pbkdf2_sha256' not in self.password: + self.password = make_password(self.password) + super().save(*args, **kwargs) + + class Meta: + unique_together = ('email', 'company') + indexes = [ + models.Index(fields=['email']), + models.Index(fields=['phone']), + ] diff --git a/chatbot/models/story_models.py b/chatbot/models/story_models.py new file mode 100644 index 0000000..e7f6198 --- /dev/null +++ b/chatbot/models/story_models.py @@ -0,0 +1,255 @@ +import io +import os +import base64 +from django.db import models +from django.core.validators import MinLengthValidator +from chatbot.models import Profile, TagChoices, StoryLanguageChoices, StorySourceChoices, MediaTypeChoices, \ + StoryStatusChoices, Company, TagSourceChoices +from pillow_heif import register_heif_opener +from django.core.files.base import ContentFile +from PIL import Image, UnidentifiedImageError +import requests + +from chatbot.services.storage import StorageFactory + +S3_BASE_URL = os.getenv('S3_MEDIA_URL') +register_heif_opener() + + +class Story(models.Model): + """ + Represents a story created by a user or AI within a chat session. + Stores content, metadata, language, status, and translation support. + """ + + title = models.CharField(max_length=1000) + author = models.ForeignKey(Profile, on_delete=models.SET_NULL, null=True, blank=True) + content = models.TextField(null=True, blank=True) + blurb = models.TextField(null=True, blank=True) + tweet = models.TextField(null=True, blank=True) + session = models.CharField(max_length=255, unique=True) + objective = models.TextField(null=True, blank=True) + action_steps = models.TextField(null=True, blank=True) + impact = models.TextField(null=True, blank=True) + micro_improvement = models.TextField(null=True, blank=True) + location = models.CharField(max_length=1000, null=True, blank=True) + district = models.CharField(max_length=1000, null=True, blank=True) + state = models.CharField(max_length=1000, null=True, blank=True) + block = models.CharField(max_length=1000, null=True, blank=True) + formatted_content = models.TextField(null=True, blank=True) + language = models.CharField(max_length=1000, choices=StoryLanguageChoices.choices, + default=StoryLanguageChoices.ENGLISH) + source = models.CharField(max_length=1000, choices=StorySourceChoices.choices, + default=StorySourceChoices.AI_GENERATED) + story_code = models.CharField(max_length=100, null=True, blank=True) + stage = models.CharField(max_length=100, choices=StoryStatusChoices.choices, default=StoryStatusChoices.PENDING) + summary = models.TextField(null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + + client_created_at = models.DateTimeField(null=True, blank=True) + client_updated_at = models.DateTimeField(null=True, blank=True) + validation_logs = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + def get_translation(self, language): + """Get story content in specified language""" + if language == self.language: + return self + + try: + return self.translations.get(language=language) + except StoryTranslation.DoesNotExist: + return None + + def get_available_languages(self): + """Get list of available languages for this story""" + langs = [self.language] + langs.extend(self.translations.values_list('language', flat=True)) + return langs + + def get_translation_languages(self): + """Get only translation languages (excludes main story language)""" + return list(self.translations.values_list('language', flat=True)) + + class Meta: + indexes = [ + models.Index(fields=['title']), + models.Index(fields=['session']), + models.Index(fields=['author']) + ] + + +class StoryMedia(models.Model): + """ + Stores media files associated with a story. + Handles file uploads, format conversion, and base64 encoding. + """ + + def get_file_upload_path(self, filename): + folder_name = 'chatbot/storymedia/{}'.format(self.story.id) + upload_path = f"{folder_name}/{filename}" + return upload_path + + name = models.CharField(max_length=1000) + file = models.FileField(upload_to=get_file_upload_path, max_length=1000, null=True, blank=True) + story = models.ForeignKey(Story, related_name='story_media', on_delete=models.CASCADE) + include_in_story = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + base64_str = models.TextField(null=True, blank=True) + source_path = models.TextField(null=True, blank=True) + media_type = models.CharField(max_length=100, choices=MediaTypeChoices.choices, null=True, blank=True) + file_url = models.CharField(max_length=2000, null=True, blank=True) + + def get_public_url(self): + if self.file: + return f"{S3_BASE_URL}{self.file.name}" + elif self.file_url: + return self.file_url + else: + return "" + + def save(self, *args, **kwargs): + try: + if self.file_url: + if self.file_url.startswith("s3://"): + storage_handler = StorageFactory.get_storage_handler() + response_content = storage_handler.get_file_from_store(self.file_url) + self.base64_str = base64.b64encode(response_content).decode('utf-8') + print("Encoded base64 from file_url") + else: + response = requests.get(self.file_url) + response.raise_for_status() + self.base64_str = base64.b64encode(response.content).decode('utf-8') + print("Encoded base64 from file_url") + + if not self.file: + super().save(*args, **kwargs) + return + self.file.seek(0) + file_ext = os.path.splitext(self.file.name)[1].lower() + print("file_ext:", file_ext) + print("File name:", self.file.name) + print("File size:", self.file.size) + + # Convert HEIC/HEIF to JPEG + if file_ext in ['.heic', '.heif']: + try: + image = Image.open(self.file) + converted_io = io.BytesIO() + image.save(converted_io, format='JPEG') + + # Replace the file with JPEG + converted_io.seek(0) + new_filename = os.path.splitext(self.file.name)[0] + ".jpg" + self.file = ContentFile(converted_io.read(), name=new_filename) + self.media_type = MediaTypeChoices.JPEG + + print("Converted HEIC/HEIF to JPEG:", new_filename) + except UnidentifiedImageError: + print("Could not identify image file. Make sure it's valid.") + except Exception as e: + print("Unexpected error during HEIF conversion:", str(e)) + + # Reset pointer before base64 encoding + self.file.seek(0) + self.base64_str = base64.b64encode(self.file.read()).decode('utf-8') + + except Exception as e: + print("Error during save():", str(e)) + + super().save(*args, **kwargs) + + +class Tag(models.Model): + """ + Represents a reusable tag used to categorize stories. + Can be company-specific and linked to a creator profile. + """ + + name = models.CharField(max_length=1000, unique=True, null=False, blank=False, + validators=[MinLengthValidator(limit_value=3)]) + status = models.CharField(max_length=100, choices=TagChoices.choices, default=TagChoices.PENDING) + company = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True) + + source_type = models.CharField( + max_length=50, choices=TagSourceChoices.choices, null=True, blank=True + ) + + description = models.TextField(null=True, blank=True) + is_theme = models.BooleanField(default=False) + icon = models.CharField(max_length=1000, null=True, blank=True) + + created_by = models.ForeignKey(Profile, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Global Tag" + verbose_name_plural = "Global Tags" + + def __str__(self): + return self.name + + +class StoryTag(models.Model): + """ + Maps tags to stories with optional primary tag designation. + Ensures a story cannot have duplicate tags. + """ + + story = models.ForeignKey(Story, on_delete=models.CASCADE) + tag = models.ForeignKey(Tag, on_delete=models.DO_NOTHING) + is_primary = models.BooleanField(default=False) + + created_by = models.ForeignKey(Profile, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.story.title} - {self.tag.name}" + + class Meta: + unique_together = ('story', 'tag') + + +class StoryTranslation(models.Model): + """ + Stores translated versions of a story in different languages. + Maintains localized content while linking to the original story. + """ + + story = models.ForeignKey(Story, related_name='translations', on_delete=models.CASCADE) + language = models.CharField(max_length=10, choices=StoryLanguageChoices.choices) + + title = models.CharField(max_length=1000) + content = models.TextField(null=True, blank=True) + blurb = models.TextField(null=True, blank=True) + tweet = models.TextField(null=True, blank=True) + objective = models.TextField(null=True, blank=True) + action_steps = models.TextField(null=True, blank=True) + impact = models.TextField(null=True, blank=True) + micro_improvement = models.TextField(null=True, blank=True) + formatted_content = models.TextField(null=True, blank=True) + location = models.CharField(max_length=1000, null=True, blank=True) + district = models.CharField(max_length=1000, null=True, blank=True) + state = models.CharField(max_length=1000, null=True, blank=True) + block = models.CharField(max_length=1000, null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('story', 'language') + indexes = [ + models.Index(fields=['story', 'language']), + ] + + def __str__(self): + return f"{self.story.title} ({self.language})" diff --git a/chatbot/models/story_vernacular_model.py b/chatbot/models/story_vernacular_model.py new file mode 100644 index 0000000..5f738ac --- /dev/null +++ b/chatbot/models/story_vernacular_model.py @@ -0,0 +1,33 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from chatbot.models import CompanyBot + + +class StoryVernacular(models.Model): + """ + Stores language-specific translations for story-related bot content. + Links a company bot to translated JSON text for a given language. + """ + + company_bot = models.ForeignKey(CompanyBot, on_delete=models.SET_NULL, related_name='story_vernacular', null=True) + + translation_json = models.JSONField( + null=True, blank=True, + help_text="JSON object containing translated text in the specified language." + ) + language = models.CharField(max_length=250, help_text="Language code, Example for English use en.") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + history = HistoricalRecords() + + class Meta: + indexes = [ + models.Index(fields=['language']), + models.Index(fields=['created_at']), + models.Index(fields=['company_bot']), + ] + + def __str__(self): + return f"StoryVernacular ({self.language}) - {self.company_bot}" diff --git a/chatbot/models/theme_models.py b/chatbot/models/theme_models.py new file mode 100644 index 0000000..aace5e9 --- /dev/null +++ b/chatbot/models/theme_models.py @@ -0,0 +1,45 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from chatbot.models import CompanyBot, ThemeType + + +class Theme(models.Model): + """ + Stores theme configurations associated with a company bot. + Supports custom story themes or inheritance from a master theme. + """ + + bot = models.ForeignKey( + CompanyBot, on_delete=models.CASCADE, related_name='themes', + help_text="Select the bot this theme belongs to." + ) + themes = models.JSONField( + default=list, blank=True, + help_text="Store a list of themes associated with this bot." + ) + + theme_type = models.CharField( + max_length=10, choices=ThemeType.choices, default=ThemeType.CUSTOM, + help_text="Indicates if this theme is custom or uses a master theme." + ) + + master_theme = models.ForeignKey( + 'self', null=True, blank=True, on_delete=models.SET_NULL, + related_name='child_themes', + help_text="If using a master theme, select the theme to inherit from." + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + def __str__(self): + return f"Themes for {self.bot.name}" + + class Meta: + verbose_name = "Theme" + verbose_name_plural = "Themes" + indexes = [ + models.Index(fields=['bot']), + models.Index(fields=['theme_type']), + ] diff --git a/chatbot/pdf/__init__.py b/chatbot/pdf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/pdf/knowledge_service/ks_report_pdf.css b/chatbot/pdf/knowledge_service/ks_report_pdf.css new file mode 100644 index 0000000..b9d4087 --- /dev/null +++ b/chatbot/pdf/knowledge_service/ks_report_pdf.css @@ -0,0 +1,294 @@ +/* Enhanced Q&A Report Styles with Corrected Page Control */ +.qa-report-container { + width: 210mm; + min-height: 296.7mm; + max-height: 296.7mm; + background: #FFFFFF; + padding: 20px 30px; + box-sizing: border-box; + font-family: 'Open Sans', sans-serif; + position: relative; + overflow: hidden; +} + +/* First Page Header Styles - Logo right, location left, title centered below */ +.first-page .qa-first-page-header { + margin-bottom: 40px; + padding-top: 0; + position: relative; +} + +.qa-first-page-logo { + position: absolute; + top: 0; + right: 0; + height: 50px; + margin-bottom: 15px; + max-width: 250px; +} + +.qa-first-page-location { + font-size: 18px; + font-weight: 500; + color: #333; + margin-bottom: 0; + line-height: 50px; /* Align with reduced logo height */ + margin-right: 160px; /* Space for smaller logo */ +} + +.qa-first-page-title { + font-size: 28px; + font-weight: 600; + color: #000; + line-height: 1.3; + margin-top: 20px; /* Space above title */ + margin-bottom: 0; + text-align: center; /* Center the title */ + clear: both; +} + +/* Continuation pages - ONLY logo, no text */ +.qa-continuation-header { + display: none; /* Hidden by default */ + position: relative; + height: 60px; + margin-bottom: 0; +} + +.qa-continuation-logo { + position: absolute; + top: 0; + right: 0; + height: 50px; + max-width: 250px; +} + +/* Remove all continuation text elements */ +.qa-continuation-info { + display: none !important; +} + +.qa-continuation-location { + display: none !important; +} + +.qa-continuation-separator { + display: none !important; +} + +.qa-continuation-title { + display: none !important; +} + +/* Content styling for all pages */ +.qa-content { + margin-top: 10px; + padding: 8px; +} + +.first-page-content { + max-height: calc(296.7mm - 200px); + overflow: hidden; +} + +.qa-content-continued { + max-height: calc(296.7mm - 100px); + overflow: hidden; +} + +.qa-section { + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 15px; + background: #ffffff; + box-shadow: 0 14px 12px rgba(0,0,0,1); + -webkit-box-shadow: 0px 0px 8px 2px rgba(0,0,0,0.15); + -moz-box-shadow: 0px 0px 8px 2px rgba(0,0,0,1); + page-break-inside: avoid !important; + break-inside: avoid !important; + min-height: 100px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; +} + +.question-number { + font-size: 1rem; + font-weight: 500; + color: #000; + flex-shrink: 0; + line-height: 1.4; + min-width: 25px; +} + +.qa-question-wrapper { + margin-bottom: 12px; + display: flex; + align-items: flex-start; + padding: 10px 12px; +} + +.question-text { + font-size: 1rem; + font-weight: 500; + color: #000; + line-height: 1.4; + flex: 1; +} + +.qa-answer-wrapper { + display: flex; + align-items: flex-start; + gap: 6px; + background: #f8f9fa; + padding: 10px; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +.answer-arrow { + font-size: 1rem; + color: #000000; + font-weight: bold; + flex-shrink: 0; + line-height: 1.4; + margin-top: 2px; + min-width: 20px; +} + +.answer-text { + font-size: 0.95rem; + font-weight: 400; + color: #333; + line-height: 1.5; + text-align: left; + flex: 1; +} + +/* Page Break Controls */ +.qa-page-break { + page-break-before: always; +} + +/* Print-specific styles */ +@media print { + .qa-report-container { + max-height: 296.7mm; + overflow: hidden; + } + + /* Page breaks */ + .qa-page-break { + page-break-before: always; + } + + /* Show continuation headers only in print - LOGO ONLY */ + .continuation-page .qa-continuation-header { + display: block; + position: relative; + height: 60px; + margin-bottom: 20px; + } + + /* Ensure NO text appears on continuation pages */ + .qa-continuation-info { + display: none !important; + } + + .qa-continuation-location { + display: none !important; + } + + .qa-continuation-separator { + display: none !important; + } + + .qa-continuation-title { + display: none !important; + } + + .qa-continuation-spacer { + height: 20px; + } + + /* First page adjustments for print */ + .first-page .qa-first-page-header { + margin-bottom: 30px; + } + + .qa-first-page-location { + font-size: 1.1rem; + margin-bottom: 12px; + font-weight: 600; + } + + .qa-first-page-title { + font-size: 24px; + } +} + +/* Screen-only styles (hide continuation elements) */ +@media screen { + .qa-continuation-header { + display: none !important; + } + + .qa-continuation-spacer { + display: none !important; + } +} + +/* Legacy styles compatibility */ +.qa-header { + display: none; /* Hide old header style */ +} + +.qa-title { + display: none; /* Hide old title style */ +} + +.qa-logo { + display: none; /* Hide old logo style */ +} + +.qa-location { + display: none; /* Hide old location style */ +} + +/* Content density adjustments */ +.qa-content-dense .qa-section { + margin-bottom: 15px; + padding: 15px; + min-height: 80px; +} + +.qa-content-sparse .qa-section { + margin-bottom: 25px; + padding: 20px; + min-height: 150px; +} + +/* Utilities */ +.qa-text-small { font-size: 0.85rem; } +.qa-text-medium { font-size: 0.95rem; } +.qa-text-large { font-size: 1.05rem; } + +.qa-spacing-tight { margin-bottom: 10px; } +.qa-spacing-normal { margin-bottom: 20px; } +.qa-spacing-loose { margin-bottom: 30px; } + +.sources-list li { + margin-bottom: 10px; + line-height: 1.6; +} + +.split-div1 { + page-break-after: always; +} + +.split-div1 .project-section { + page-break-before: auto; + page-break-after: auto; +} \ No newline at end of file diff --git a/chatbot/pdf/knowledge_service/project_report_pdf.py b/chatbot/pdf/knowledge_service/project_report_pdf.py new file mode 100644 index 0000000..66aa49a --- /dev/null +++ b/chatbot/pdf/knowledge_service/project_report_pdf.py @@ -0,0 +1,320 @@ +import os +from django.core.files.base import ContentFile + +from chatbot.models import ChatSession +from chatbot.models.story_vernacular_model import StoryVernacular +from chatbot.utils.gotenberg_utils import generate_pdf_with_gotenberg + + +def format_sources_html(sources): + print("Sources check:", sources) + + if not sources or not isinstance(sources, dict): + return '
  • No sources available
  • ' + + added_urls = set() + source_items = [] + + for key in ['objective_chunk', 'action_chunk']: + data_list = sources.get(key) + + if not data_list or not isinstance(data_list, list): + continue + + for data in data_list: + if not isinstance(data, dict): + continue + + title = data.get('title') + url = data.get('url') + + if title and url and url not in added_urls: + added_urls.add(url) + source_items.append( + f'
  • {title}
  • ' + ) + + if not source_items: + return '
  • No sources available
  • ' + + return ( + "
    " + "
    " + "" + "Sources" + "
    " + "
    " + "
      " + + "\n".join(source_items) + + "
    " + "
    " + "
    " + ) + + +def get_project_report_html( + project_title, + author_name, + location, + problem_statement, + objective, + timeline, + action_steps, + language, + session, + sources=None +): + """Generate HTML for project report PDF matching the screenshot design""" + + # Format action steps as numbered list + action_steps_html = "" + if action_steps and isinstance(action_steps, list): + for i, step in enumerate(action_steps, 1): + action_steps_html += f'
  • {step}
  • \n' + + objective_html = "" + if objective and isinstance(objective, list): + for i, obj in enumerate(objective, 1): + objective_html += f'
  • {obj}
  • \n' + else: + objective_html = objective or "" + + chat_session = ChatSession.objects.filter(session=session).first() + sources_char_limit=1200 + if chat_session: + print("ChatSession found!") + story_vernacular = StoryVernacular.objects.filter( + company_bot=chat_session.company_bot, language=language + ).first() + if story_vernacular: + print("StoryVernacular found!") + sources_char_limit = story_vernacular.translation_json.get('sources_char_limit', 1200) + + # Format sources as numbered list + sources_html = format_sources_html(sources) + print("sources_html: ", sources_html) + # Get CSS path (using the story PDF CSS as base) + css_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "ks_report_pdf.css")) + + # Read CSS file + try: + with open(css_path, 'r') as css_file: + inline_css = css_file.read() + except: + # Fallback CSS if file not found + inline_css = get_project_report_css() + + html_content = f""" + + + + + {project_title} + + + + + + +
    +
    +

    {project_title}

    +
    + +
    +
    + + Problem statement +
    +
    + {problem_statement} +
    +
    + +
    +
    + + Objective +
    +
    + { + f"
      {objective_html}
    " + if isinstance(objective, list) + else objective_html + } +
    +
    + +
    +
    + + Timeline +
    +
    + {timeline} +
    +
    + +
    +
    + + Action steps +
    +
    +
      + {action_steps_html} +
    +
    +
    + + {sources_html} +
    + + + """ + + return html_content + + +def get_project_report_css(): + """Return CSS styles for project report PDF""" + return """ + /* Project Report Specific Styles */ + body { + font-family: 'Open Sans', 'Manrope', sans-serif; + margin: 0; + padding: 40px; + color: #333; + background-color: #ffffff; + } + + .project-report-container { + max-width: 800px; + margin: 0 auto; + background: white; + padding: 20px; + } + + .project-header { + margin-bottom: 40px; + } + + .project-title { + font-size: 24px; + font-weight: 600; + color: #2c3e50; + margin: 0 0 10px 0; + line-height: 1.3; + } + + .project-author { + font-size: 14px; + color: #666; + line-height: 1.5; + } + + .project-section { + margin-bottom: 30px; + border: 1px solid #e1e4e8; + border-radius: 8px; + padding: 20px; + background-color: #f8f9fa; + page-break-inside: avoid; + } + + .section-header { + display: flex; + align-items: center; + margin-bottom: 15px; + gap: 8px; + } + + .star-icon { + color: #47aa8c; + font-size: 20px; + } + + .section-title { + font-size: 18px; + font-weight: 600; + color: #47aa8c; + } + + .section-content { + font-size: 14px; + line-height: 1.6; + color: #333; + padding-left: 28px; + } + + .action-steps-list, + .sources-list { + margin: 0; + padding-left: 20px; + list-style-type: decimal; + } + + .action-steps-list li, + .sources-list li { + margin-bottom: 10px; + line-height: 1.6; + } + + /* Print styles */ + @media print { + body { + padding: 20px; + } + + .project-section { + break-inside: avoid; + } + } + """ + + +def generate_project_pdf( + project_title, + author_name, + location, + problem_statement, + objective, + timeline, + action_steps, + language, + session, + sources=None, + pdf_filename=None, +): + """Generate PDF for project report""" + + # Generate HTML content + html_content = get_project_report_html( + project_title=project_title, + author_name=author_name, + location=location, + problem_statement=problem_statement, + objective=objective, + timeline=timeline, + action_steps=action_steps, + language=language, + session=session, + sources=sources + ) + + # Generate PDF using gotenberg + pdf_generated = generate_pdf_with_gotenberg(html_content) + + # Set default filename if not provided + if not pdf_filename: + pdf_filename = f"{project_title}.pdf" if project_title else "Project_Report.pdf" + + # Create ContentFile + pdf_content = ContentFile(pdf_generated, name=pdf_filename) + + print(f"PDF report generated: {pdf_filename}") + + return pdf_content \ No newline at end of file diff --git a/chatbot/pdf/listening_activity/la_report.py b/chatbot/pdf/listening_activity/la_report.py new file mode 100644 index 0000000..076dcd7 --- /dev/null +++ b/chatbot/pdf/listening_activity/la_report.py @@ -0,0 +1,262 @@ +from chatbot.models import StoryTranslation + + +def get_common_report_html(story, profile, story_vernacular=None): + # Get data from other_params + other_params = story.other_params or {} + question_answers = other_params.get('question_answers', []) + state = other_params.get('state', '') + block = other_params.get('block', '') + district = other_params.get('district', '') + company_logo = other_params.get('company_logo', '') + + # Get title and logo from story_vernacular if available + title = story.title + char_limit = 800 # More conservative default character limit per page + qa_per_page = 3 # Default QA pairs per page + max_page_height = 'auto' # Default page height constraint + + if story_vernacular: + translation_json = story_vernacular.translation_json or {} + title = translation_json.get('title', story.title) + company_logo = translation_json.get('main_logo', company_logo) + + # Enhanced pagination settings from translation_json + qa_box_height = translation_json.get('qa_box_height', 'auto') + char_limit = translation_json.get('qa_char_limit', char_limit) + qa_per_page = translation_json.get('qa_per_page', qa_per_page) + max_page_height = translation_json.get('max_page_height', max_page_height) + + # Additional page control settings + force_page_break_after = translation_json.get('force_page_break_after', None) # QA numbers to force break after + min_qa_per_page = translation_json.get('min_qa_per_page', 1) # Minimum QAs before allowing page break + page_break_strategy = translation_json.get('page_break_strategy', 'mixed') # 'char_limit', 'qa_count', 'mixed' + + else: + qa_box_height = 'auto' + force_page_break_after = None + min_qa_per_page = 1 + page_break_strategy = 'mixed' + + # Build location string + location_parts = [part for part in [block, district, state] if part] + location_string = ", ".join(location_parts) + + # Process QA pairs with enhanced pagination control + qa_chunks = chunk_qa_with_page_control( + question_answers, + char_limit, + qa_box_height, + qa_per_page, + max_page_height, + force_page_break_after, + min_qa_per_page, + page_break_strategy, + location_string, + company_logo + ) + + # Build HTML with proper page structure and different layouts for first vs subsequent pages + full_html = "" + for i, chunk_html in enumerate(qa_chunks): + if i == 0: + # First page layout - logo, location and title + full_html += f""" +
    +
    + {f'' if company_logo else ''} +
    {location_string}
    +
    {title}
    +
    +
    + {chunk_html} +
    +
    + """ + else: + # Subsequent pages - only logo in top right, minimal header + full_html += f""" +
    +
    +
    + {f'' if company_logo else ''} +
    +
    +
    + {chunk_html} +
    +
    + """ + + return full_html + + +def chunk_qa_with_page_control(question_answers, char_limit, qa_box_height, qa_per_page, + max_page_height, force_page_break_after, min_qa_per_page, + page_break_strategy, location_string, company_logo): + """ + Enhanced QA chunking with multiple pagination strategies and controls + """ + chunks = [] + current_chunk_html = "" + current_char_count = 0 + current_qa_count = 0 + display_number = 1 # Separate counter for displayed question numbers + + for i, qa in enumerate(question_answers, 1): + # Check if QA has both question and non-empty answer + if (isinstance(qa, dict) and + qa.get('question') and + qa.get('answer') and + str(qa.get('answer')).strip()): # Check for non-empty answer + + qa_html = f""" +
    +
    + {display_number}. + {clean_escaped_text(qa['question'])} +
    +
    + + {clean_escaped_text(qa['answer'])} +
    +
    + """ + + qa_text_length = len(qa.get('question', '')) + len(qa.get('answer', '')) + + # Determine if we should break to a new page + should_break = False + + if page_break_strategy == 'char_limit': + # Character limit strategy + should_break = (current_char_count + qa_text_length > char_limit and + current_qa_count >= min_qa_per_page) + + elif page_break_strategy == 'qa_count': + # QA count strategy + should_break = (current_qa_count >= qa_per_page and + current_qa_count >= min_qa_per_page) + + elif page_break_strategy == 'mixed': + # Mixed strategy (default) - break on either condition + should_break = ((current_char_count + qa_text_length > char_limit or + current_qa_count >= qa_per_page) and + current_qa_count >= min_qa_per_page) + + # Force page break after specific QA numbers (use display_number for comparison) + if force_page_break_after and display_number - 1 in force_page_break_after: + should_break = True + + # Execute page break if conditions are met and we have content + if should_break and current_chunk_html: + chunks.append(current_chunk_html) + current_chunk_html = qa_html + current_char_count = qa_text_length + current_qa_count = 1 + else: + current_chunk_html += qa_html + current_char_count += qa_text_length + current_qa_count += 1 + + # Increment display number only when we actually display a QA + display_number += 1 + + # Add the last chunk if there's any content + if current_chunk_html: + chunks.append(current_chunk_html) + + return chunks + + +def estimate_content_height(qa_count, qa_box_height, base_height=200): + """ + Estimate the total height of content based on QA count and box height + Useful for max_page_height calculations + """ + if qa_box_height == 'auto': + estimated_qa_height = 150 # Default estimated height per QA + else: + try: + estimated_qa_height = int(qa_box_height.replace('px', '')) + except: + estimated_qa_height = 150 + + return base_height + (qa_count * estimated_qa_height) + + +def get_optimal_pagination_settings(question_answers, target_pages=None): + """ + Calculate optimal pagination settings based on content analysis + Only counts Q&As that have non-empty answers + """ + # Filter out Q&As with empty answers for calculation + valid_qas = [qa for qa in question_answers + if isinstance(qa, dict) and qa.get('question') and + qa.get('answer') and str(qa.get('answer')).strip()] + + total_qas = len(valid_qas) + total_chars = sum(len(qa.get('question', '')) + len(qa.get('answer', '')) + for qa in valid_qas) + + if target_pages and total_qas > 0: + # Calculate settings to fit content into target number of pages + qa_per_page = max(1, total_qas // target_pages) + char_limit = max(400, total_chars // target_pages) + else: + # Use default heuristics + avg_chars_per_qa = total_chars / total_qas if total_qas > 0 else 400 + + if avg_chars_per_qa > 200: + qa_per_page = 3 + char_limit = 600 + else: + qa_per_page = 4 + char_limit = 800 + + return { + 'qa_per_page': qa_per_page, + 'qa_char_limit': char_limit, + 'estimated_pages': (total_qas + qa_per_page - 1) // qa_per_page if total_qas > 0 else 0 + } + + +def clean_escaped_text(text): + if not text: + return "" + text = str(text).replace("\\'", "'") # \' → ' + text = text.replace('\\"', '"') # \" → " + text = text.replace("\\\\", "\\") # \\ → \ + return text.strip() + + +def get_generic_story_in_language(story, language='en'): + """Get generic story content in specified language""" + if language == 'en' or language == story.language: + return { + 'title': story.title, + 'content': story.content, + 'tweet': story.tweet, + 'objective': story.objective, + 'action_steps': story.action_steps, + 'impact': story.impact, + 'micro_improvement': story.micro_improvement, + 'blurb': story.blurb, + 'other_params': story.other_params, + } + + try: + translation = story.translations.get(language=language) + return { + 'title': translation.title, + 'content': translation.content, + 'tweet': translation.tweet, + 'objective': translation.objective, + 'action_steps': translation.action_steps, + 'impact': translation.impact, + 'micro_improvement': translation.micro_improvement, + 'blurb': translation.blurb, + 'other_params': translation.other_params, + } + except StoryTranslation.DoesNotExist: + return get_generic_story_in_language(story, 'en') diff --git a/chatbot/pdf/listening_activity/la_report_pdf.css b/chatbot/pdf/listening_activity/la_report_pdf.css new file mode 100644 index 0000000..f551487 --- /dev/null +++ b/chatbot/pdf/listening_activity/la_report_pdf.css @@ -0,0 +1,280 @@ +/* Enhanced Q&A Report Styles with Corrected Page Control */ +.qa-report-container { + width: 210mm; + min-height: 296.7mm; + max-height: 296.7mm; + background: #FFFFFF; + padding: 20px 30px; + box-sizing: border-box; + font-family: 'Open Sans', sans-serif; + position: relative; + overflow: hidden; +} + +/* First Page Header Styles - Logo right, location left, title centered below */ +.first-page .qa-first-page-header { + margin-bottom: 40px; + padding-top: 0; + position: relative; +} + +.qa-first-page-logo { + position: absolute; + top: 0; + right: 0; + height: 50px; + margin-bottom: 15px; + max-width: 250px; +} + +.qa-first-page-location { + font-size: 18px; + font-weight: 500; + color: #333; + margin-bottom: 0; + line-height: 50px; /* Align with reduced logo height */ + margin-right: 160px; /* Space for smaller logo */ +} + +.qa-first-page-title { + font-size: 28px; + font-weight: 600; + color: #000; + line-height: 1.3; + margin-top: 20px; /* Space above title */ + margin-bottom: 0; + text-align: center; /* Center the title */ + clear: both; +} + +/* Continuation pages - ONLY logo, no text */ +.qa-continuation-header { + display: none; /* Hidden by default */ + position: relative; + height: 60px; + margin-bottom: 0; +} + +.qa-continuation-logo { + position: absolute; + top: 0; + right: 0; + height: 50px; + max-width: 250px; +} + +/* Remove all continuation text elements */ +.qa-continuation-info { + display: none !important; +} + +.qa-continuation-location { + display: none !important; +} + +.qa-continuation-separator { + display: none !important; +} + +.qa-continuation-title { + display: none !important; +} + +/* Content styling for all pages */ +.qa-content { + margin-top: 10px; + padding: 8px; +} + +.first-page-content { + max-height: calc(296.7mm - 200px); + overflow: hidden; +} + +.qa-content-continued { + max-height: calc(296.7mm - 100px); + overflow: hidden; +} + +.qa-section { + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 15px; + background: #ffffff; + box-shadow: 0 14px 12px rgba(0,0,0,1); + -webkit-box-shadow: 0px 0px 8px 2px rgba(0,0,0,0.15); + -moz-box-shadow: 0px 0px 8px 2px rgba(0,0,0,1); + page-break-inside: avoid !important; + break-inside: avoid !important; + min-height: 100px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; +} + +.question-number { + font-size: 1rem; + font-weight: 500; + color: #000; + flex-shrink: 0; + line-height: 1.4; + min-width: 25px; +} + +.qa-question-wrapper { + margin-bottom: 12px; + display: flex; + align-items: flex-start; + padding: 10px 12px; +} + +.question-text { + font-size: 1rem; + font-weight: 500; + color: #000; + line-height: 1.4; + flex: 1; +} + +.qa-answer-wrapper { + display: flex; + align-items: flex-start; + gap: 6px; + background: #f8f9fa; + padding: 10px; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +.answer-arrow { + font-size: 1rem; + color: #000000; + font-weight: bold; + flex-shrink: 0; + line-height: 1.4; + margin-top: 2px; + min-width: 20px; +} + +.answer-text { + font-size: 0.95rem; + font-weight: 400; + color: #333; + line-height: 1.5; + text-align: left; + flex: 1; +} + +/* Page Break Controls */ +.qa-page-break { + page-break-before: always; +} + +/* Print-specific styles */ +@media print { + .qa-report-container { + max-height: 296.7mm; + overflow: hidden; + } + + /* Page breaks */ + .qa-page-break { + page-break-before: always; + } + + /* Show continuation headers only in print - LOGO ONLY */ + .continuation-page .qa-continuation-header { + display: block; + position: relative; + height: 60px; + margin-bottom: 20px; + } + + /* Ensure NO text appears on continuation pages */ + .qa-continuation-info { + display: none !important; + } + + .qa-continuation-location { + display: none !important; + } + + .qa-continuation-separator { + display: none !important; + } + + .qa-continuation-title { + display: none !important; + } + + .qa-continuation-spacer { + height: 20px; + } + + /* First page adjustments for print */ + .first-page .qa-first-page-header { + margin-bottom: 30px; + } + + .qa-first-page-location { + font-size: 1.1rem; + margin-bottom: 12px; + font-weight: 600; + } + + .qa-first-page-title { + font-size: 24px; + } +} + +/* Screen-only styles (hide continuation elements) */ +@media screen { + .qa-continuation-header { + display: none !important; + } + + .qa-continuation-spacer { + display: none !important; + } +} + +/* Legacy styles compatibility */ +.qa-header { + display: none; /* Hide old header style */ +} + +.qa-title { + display: none; /* Hide old title style */ +} + +.qa-logo { + display: none; /* Hide old logo style */ +} + +.qa-location { + display: none; /* Hide old location style */ +} + +/* Content density adjustments */ +.qa-content-dense .qa-section { + margin-bottom: 15px; + padding: 15px; + min-height: 80px; +} + +.qa-content-sparse .qa-section { + margin-bottom: 25px; + padding: 20px; + min-height: 150px; +} + +/* Utilities */ +.qa-text-small { font-size: 0.85rem; } +.qa-text-medium { font-size: 0.95rem; } +.qa-text-large { font-size: 1.05rem; } + +.qa-spacing-tight { margin-bottom: 10px; } +.qa-spacing-normal { margin-bottom: 20px; } +.qa-spacing-loose { margin-bottom: 30px; } diff --git a/chatbot/pdf/shiksha_chaupal/mom_report.py b/chatbot/pdf/shiksha_chaupal/mom_report.py new file mode 100644 index 0000000..16a6e5e --- /dev/null +++ b/chatbot/pdf/shiksha_chaupal/mom_report.py @@ -0,0 +1,376 @@ +import re +import logging +import json_repair +from jinja2 import Template +from chatbot.models import PDFTemplates +from chatbot.pdf.shiksha_chaupal.story_images_page import get_report_images_page_html +from datetime import datetime + +logger = logging.getLogger('django') + + +def get_mom_report_html(story, story_vernacular, voice_provider, profile): + """ + Generate MOM report HTML. Tries to use Jinja2 template from database first, + falls back to hardcoded HTML if template not found. + """ + # Extract raw data + if story.other_params: + challenges_faced = story.other_params.get('challenges_faced') + solutions_discussed = story.other_params.get('solutions_discussed') + remarks = story.other_params.get('remarks') + else: + challenges_faced, solutions_discussed, remarks = None, None, None + + translation_json = story_vernacular.translation_json + if translation_json: + translation_json = translation_json.get('second_page', {}) + else: + translation_json = {} + + challenges_char_limit = translation_json.get('challenges_char_limit', None) + first_challenges_char_limit = translation_json.get('first_challenges_char_limit', None) + solutions_char_limit = translation_json.get('solutions_char_limit', None) + remarks_char_limit = translation_json.get('remarks_char_limit', None) + + # Process chunks for Jinja2 template + challenges_chunks = process_steps_to_chunks( + raw_data=challenges_faced, + fallback_text=translation_json.get('no_challenges_faced_text', ""), + char_limit=challenges_char_limit, + first_char_limit=first_challenges_char_limit, + is_challenges=True + ) + + solutions_chunks = process_steps_to_chunks( + raw_data=solutions_discussed, + fallback_text=translation_json.get('no_solutions_text', ""), + char_limit=solutions_char_limit + ) + + remarks_chunks = process_steps_to_chunks( + raw_data=remarks, + fallback_text=translation_json.get('no_remarks_text', ""), + char_limit=remarks_char_limit + ) + + # Get processed user details + author, address_string, company_logo, date_of_discussion, participants_info, organization = get_user_details( + story=story, profile=profile, voice_provider=voice_provider, translation_json=translation_json + ) + + if hasattr(story, 'story'): + story_obj = story.story + else: + story_obj = story + + # Get images HTML + images_html = get_report_images_page_html(story=story_obj) + + # Try to use Jinja2 template from database + try: + pdf_template = PDFTemplates.objects.get(template_name='chaupal_mom_report') + logger.info("[MOM PDF] Using Jinja2 template from database") + + # Build context for Jinja2 template with objects + context = { + # Core objects for direct access + 'story': story, + 'profile': profile, + 'translation_json': translation_json, + + # Processed chunks + 'challenges_chunks': challenges_chunks, + 'solutions_chunks': solutions_chunks, + 'remarks_chunks': remarks_chunks, + + # Processed values + 'date_of_discussion': date_of_discussion, + 'participants_info': participants_info, + 'company_logo': company_logo, + 'images_html': images_html, + } + + # Merge constants from template if available + if pdf_template.constants_json: + context = {**pdf_template.constants_json, **context} + + template = Template(pdf_template.template) + return template.render(**context) + + except PDFTemplates.DoesNotExist: + logger.warning("[MOM PDF] Template not found, using legacy HTML generation") + # Fallback to legacy hardcoded HTML + pass + except Exception as e: + logger.error(f"[MOM PDF] Error rendering template: {e}", exc_info=True) + # Fallback to legacy hardcoded HTML + pass + + # Legacy fallback: Generate HTML directly + challenges_html = process_steps( + raw_data=challenges_faced, + fallback_text=translation_json.get('no_challenges_faced_text', ""), + heading=translation_json.get('heading2', "Challenges"), + char_limit=challenges_char_limit, + first_char_limit=first_challenges_char_limit, + is_challenges=True + ) + + solutions_html = process_steps( + raw_data=solutions_discussed, + fallback_text=translation_json.get('no_solutions_text', ""), + heading=translation_json.get('heading3', "Solutions"), + char_limit=solutions_char_limit + ) + + remarks_html = process_steps( + raw_data=remarks, + fallback_text=translation_json.get('no_remarks_text', ""), + heading=translation_json.get('heading4', "Remarks"), + char_limit=remarks_char_limit + ) + + organization_html = f"

    {organization}

    " if organization else "" + date_html = f"

    {translation_json.get('dateHeader', 'Date of discussion')}: {date_of_discussion}

    " if date_of_discussion else "" + participants_html = f"

    {participants_info}

    " if participants_info else "" + + page_html = f""" +
    +
    +
    + Bottom Logo +
    +
    +

    {story.title}

    +

    {author if author else ""}

    + {organization_html} +

    {address_string}

    + {date_html} + {participants_html} + + {challenges_html if challenges_faced not in [None, [], [""]] else ""} + {solutions_html if solutions_discussed not in [None, [], [""]] else ""} + {remarks_html if remarks not in [None, [], [""], ""] else ""} + {images_html} +
    + """ + return page_html + + +def process_steps_to_chunks(raw_data, fallback_text, char_limit, first_char_limit=None, is_challenges=False): + """ + Process steps and return chunks (arrays) for Jinja2 template. + Similar to process_steps but returns data instead of HTML. + """ + if isinstance(raw_data, str): + try: + if raw_data.strip().startswith("["): + raw_data = json_repair.repair_json(raw_data, return_objects=True) + else: + raw_data = [raw_data] + except Exception as e: + raw_data = [fallback_text] + + steps = ( + [clean_escaped_text(step) for step in raw_data] if isinstance(raw_data, list) + else [clean_escaped_text(raw_data)] if isinstance(raw_data, str) + else [fallback_text] + ) + + if steps and isinstance(steps, list) and len(steps) == 1 and isinstance(steps[0], str): + steps_text = steps[0] + split_steps = re.findall(r'\d+\.\s*[^0-9]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + if not split_steps: + split_steps = steps + elif steps and isinstance(steps, str): + steps_text = " ".join(steps) + split_steps = re.findall(r'\d+\.\s*[^.]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + else: + split_steps = [step.strip() for step in steps if step.strip()] + + # Chunking logic + chunks = [] + current_chunk = [] + current_length = 0 + chunk_index = 0 + + for step in split_steps: + current_limit = first_char_limit if is_challenges and chunk_index == 0 else char_limit or 1200 + step_len = len(step) + + if current_length + step_len > current_limit and current_chunk: + chunks.append(current_chunk) + current_chunk = [step] + current_length = step_len + chunk_index += 1 + else: + current_chunk.append(step) + current_length += step_len + + if current_chunk: + chunks.append(current_chunk) + + return chunks if chunks else [[fallback_text]] + + +def clean_escaped_text(text): + text = text.replace("\\'", "") # \' → ' + text = text.replace('\\"', '') # \" → " + text = text.replace("\\\\", "") # \\ → \ + print("Text: ", text) + return text + + +def process_steps(raw_data, fallback_text, char_limit, first_char_limit=None, heading=None, is_challenges=False): + if isinstance(raw_data, str): + try: + if raw_data.strip().startswith("["): + raw_data = json_repair.repair_json(raw_data, return_objects=True) + else: + raw_data = [raw_data] + except Exception as e: + raw_data = [fallback_text] + + steps = ( + [clean_escaped_text(step) for step in raw_data] if isinstance(raw_data, list) + else [clean_escaped_text(raw_data)] if isinstance(raw_data, str) + else [fallback_text] + ) + + if steps and isinstance(steps, list) and len(steps) == 1 and isinstance(steps[0], str): + steps_text = steps[0] + split_steps = re.findall(r'\d+\.\s*[^0-9]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + if not split_steps: + split_steps = steps + elif steps and isinstance(steps, str): + steps_text = " ".join(steps) + split_steps = re.findall(r'\d+\.\s*[^.]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + else: + split_steps = [step.strip() for step in steps if step.strip()] + + # Determine chunking logic + chunks = [] + current_chunk = [] + current_length = 0 + chunk_index = 0 + + for step in split_steps: + current_limit = first_char_limit if is_challenges and chunk_index == 0 else char_limit or 1200 + step_len = len(step) + + if current_length + step_len > current_limit and current_chunk: + chunks.append(current_chunk) + current_chunk = [step] + current_length = step_len + chunk_index += 1 + else: + current_chunk.append(step) + current_length += step_len + + if current_chunk: + chunks.append(current_chunk) + + # Build HTML with page breaks between chunks + full_html = "" + current_number = 1 # Counter to maintain numbering across chunks + for i, chunk in enumerate(chunks): + # Do not apply page-break to the last chunk + page_break = "split-div1" if i < len(chunks) - 1 else "" + # Only add page break if it's not the last chunk + html = ( + f"
    " + f"
    " + f"
    " + "
    " + + (f"

    {heading}

    " if heading else "") + + "
      " + + ''.join(f"
    1. {current_number + idx}. {step}
    2. " for idx, step in enumerate(chunk)) + + "
    " + ) + full_html += html + # Update the current_number after the chunk + current_number += len(chunk) + + return full_html or fallback_text + + +def format_participants_count(participants_count, translation_json): + """Format participants count showing only non-zero values and include total.""" + if not participants_count or not isinstance(participants_count, dict): + return None + + def safe_int(value): + try: + return int(value) + except (ValueError, TypeError): + return 0 + + participant_parts = [] + print("participants_count: ", participants_count) + total = safe_int(participants_count.get('total')) + + # Get labels from translation_json (fallbacks if not present) + women_label = translation_json.get('womenLabel', 'Women') + men_label = translation_json.get('menLabel', 'Men') + children_label = translation_json.get('childrenLabel', 'Children') + + # Women + women_count = safe_int(participants_count.get('women')) + if women_count > 0: + participant_parts.append(f"{women_count}{women_label.lower()}") + + # Men + men_count = safe_int(participants_count.get('men')) + if men_count > 0: + participant_parts.append(f"{men_count}{men_label.lower()}") + + # Children + children_count = safe_int(participants_count.get('children')) + if children_count > 0: + participant_parts.append(f"{children_count}{children_label.lower()}") + + if not participant_parts and total <= 0: + return None + participants_label=translation_json.get('memberHeader', 'Total Participants') + return f"{participants_label}: {total}" if not participant_parts else f"{participants_label}: {total} [{', '.join(participant_parts)}]" + + +def get_user_details(story, profile, voice_provider, translation_json): + company_logo = translation_json.get('main_logo', '') + print("logo: ", company_logo) + + author = profile.first_name if profile and profile.first_name else "" + if not profile or not profile.first_name: + author = story.other_params.get('user_name', '') if story.other_params else '' + address_string = story.other_params.get('location', '') if story.other_params else '' + + date_of_discussion = story.other_params.get('discussion_date', None) + date_of_discussion = format_date_to_ddmmyyyy(date_of_discussion) + + # Get organization + organization = story.other_params.get('organization', '') if story.other_params else '' + + # Process participants count + participants_count = story.other_params.get('participants_count', None) if story.other_params else None + participants_info = format_participants_count(participants_count, translation_json) + + return author, address_string, company_logo, date_of_discussion, participants_info, organization + + +def format_date_to_ddmmyyyy(date_value): + if isinstance(date_value, datetime): + return date_value.strftime("%d/%m/%Y") + elif isinstance(date_value, str): + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%m/%d/%Y", "%Y/%m/%d", "%d/%m/%Y"): + try: + return datetime.strptime(date_value, fmt).strftime("%d/%m/%Y") + except ValueError: + continue + return "" diff --git a/chatbot/pdf/shiksha_chaupal/mom_report_pdf.css b/chatbot/pdf/shiksha_chaupal/mom_report_pdf.css new file mode 100644 index 0000000..f154c99 --- /dev/null +++ b/chatbot/pdf/shiksha_chaupal/mom_report_pdf.css @@ -0,0 +1,1051 @@ +/* story first page */ + + .story-company-div-fmt1 { + background-color: #f1f1f1; + } + + .nagaland-logo-div { + width: 100%; + margin-top: 40px; + display: flex; + flex-direction: row; + align-items: center; + gap: 40px; + justify-content: center; + } + + .nagaland-company-logo-div { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0 50px; + } + + .story-company-div1 { + position: relative; + width: 210mm; + height: 296.7mm; + background: #FFFFFF; + text-align: center; /* Centers logos horizontally */ + } + + .story-company-div { + position: relative; + width: 210mm; + height: 296.7mm; + background: #FFFFFF; + } + + html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + font-family: 'Open Sans', sans-serif; + background-color: #f1f1f1; + + } + + + .story-company-text-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2.5rem; + line-height: 29px; + letter-spacing: normal; + text-align: center; + color: #000; + margin: 46px 0 30px 0; + + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; + } + + .story-title-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 2rem; + text-align: center; + letter-spacing: normal; + color: #000; + line-height: 3.3rem; + margin: 10px 70px; + + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; + } + + .story-nagaland-logo-div { + display: flex; + flex-direction: column; + margin: 0; + } + + .nagaland-image-div { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + .story-bg1-fmt1{ + max-width: 495px; + height: auto; + margin: 46px 0 25px 0; + } + + .story-logo-fmt1{ + max-width: 119px; + height: auto; + padding: 10px; + } + + .story-logo1-fmt1{ + max-width: 182px; + height: auto; + } + + .story-logo2-fmt1{ + max-width: 195px; + height: auto; + } + + .story-firstpage-bottom-div { + display: flex; + justify-content: center; + align-items: center; + width: 89%; + margin: 51px auto; + } + + .story-firstpage-bottom-in-div { + display: flex; + justify-content: space-evenly; + align-items: center; + width: 100%; + background: #FEFCF3; + padding: 15px 0; + } + + .story-logo3-fmt1{ + max-width: 107px; + height: auto; + } + + .story-logo3-fmt1-normal { + max-width: 132px; + height: auto; + } + + .story-author-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 1.7rem; + line-height: 2rem; + text-align: center; + color: #000; + margin: 20px 0; + } + + .story-school-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 1.7rem; + line-height: 19px; + text-align: center; + color: #000; + margin: 0; + } + + .story-normal-state-div { + display: flex; + align-items: center; + justify-content: space-evenly; + margin: 0 auto; + } + + .story-logo-div{ + position: absolute; + width: 90%; + top: 5%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .story-logo-div{ + position: absolute; + width: 100%; + top: 5%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .story-logo-div1{ + position: absolute; + width: 100%; + height: 10%; + top: 5%; + display: flex; + justify-content: center; + align-items: center; + } + + .story-company-logo-First{ + position: relative; + width: 400px; + height: auto; + display: inline; + } + + .story-company-logo{ + position: relative; + max-width: none; + right: 290px; + bottom: 51px; + width: 736px; + height: auto; + display: inline; + } + + .story-company-logo1 { + position: relative; + max-width: none; + margin-right: 10px; + width: 122px; + height: auto; + display: inline-block; + } + + .story-shikshalokam-logo { + position: relative; + max-width: none; + width: 227px; + height: auto; + display: inline-block; + padding: 10px; + } + + .story-shikshalokam-logo-normal { + position: relative; + max-width: none; + margin-right: 19px; + width: 281px; + height: auto; + display: inline-block; +} + + .story-govt-logo { + position: relative; + max-width: none; + width: 83px; + height: auto; + display: inline-block; + padding: 10px; + } + + .story-haryana-logo { + position: relative; + max-width: none; + width: 125px; + height: auto; + display: inline-block; + } + + + .story-bg0{ + position: absolute; + width: 100%; + height: 100%; + left: 0px; + background: linear-gradient(0.38deg, #4192A6 9.33%, rgba(65, 146, 166, 0) 58.17%); + } + + .story-bg1{ + position: absolute; + width: 588px; + height: 622px; + left: calc(50% - 588px/2 + 0.5px); + top: calc(50% - 622px/2 - 33px); + } + + .story-bg2{ + position: absolute; + width: 470px; + height: 513px; + left: calc(50% - 470px/2 + 0.5px); + top: calc(50% - 513px/2 - 35.5px); + } + + .story-title{ + position: relative; + height: auto; + top: 898px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 23px; + text-align: center; + color: #FFFFFF; + margin: 0; + } + + .story-author{ + position: relative; + height: auto; + top: 931px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 20px; + text-align: center; + color: #FFFFFF; + margin: 0; + } + + .story-link{ + position: relative; + display: block; + height: auto; + top: 956px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 16px; + text-align: center; + text-decoration-line: underline; + color: #FFFFFF; + margin: 0; + } + + .story-link1{ + position: relative; + width: 122px; + height: auto; + top: 956px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 16px; + text-align: center; + text-decoration-line: underline; + color: #FFFFFF; + } + + .story-details-div { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + align-items: center; + } + + .step-li-items { + padding: 30px 0 0 0; + } + + /* story second page */ + + .story-second-page-container { + width: 100%; + background-color: #f1f1f1; + padding: 20px 40px; + box-sizing: border-box; + page-break-inside: avoid; + } + .empty-page-break { + page-break-before: always; + display: none; /* Prevent it from taking visible space */ + height: 0; + } + + .secondpage-order-list { + list-style-type: none; + padding-left: 20px; + } + + .split-div{ + margin-bottom: 20px; + } + + .split-div1{ + page-break-after: always; + } + + .second-main-sec-wrapper { + page-break-after: auto; + page-break-inside: avoid; + } + + .second-main-sec-wrapper1 { + page-break-after: avoid; + page-break-inside: avoid; + } + + .story-second-page-container h1 { + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2rem; + line-height: 2rem; + letter-spacing: normal; + color: #000; + margin: 40px 0 30px 0; + text-align: center; + } + .story-second-page-container p { + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 1rem; + line-height: 1.5rem; + letter-spacing: normal; + color: #000; + margin: 0px 0 10px 0; + text-align: center; + } + + .second-main-sec-div { + padding: 20px 0 0 0; + } + + .story-second-page-section { + margin-bottom: 20px; + padding: 20px; + border: 2px solid #ddd; + border-radius: 20px; + background-color: #fff; + page-break-inside: avoid; + } + .story-action-steps { + page-break-before: auto; /* Ensure steps start on a new page if needed */ + page-break-inside: auto; /* Avoid breaking inside a step */ + page-break-after: auto; /* Continue on the next page if needed */ + } + .story-second-page-section h2 { + display: flex; + align-items: center; + font-size: 1.2rem; + color: #3a8479; + margin-bottom: 10px; + } + .story-second-page-section h2::before { + content: "★"; + color: #3a8479; + font-size: 20px; + margin-right: 10px; + } + .story-second-page-section p, .story-second-page-section ol { + font-size: 1rem; + color: #333; + margin: 0; + } + .story-second-page-section ol { + padding-left: 20px; + margin-top: 10px; + } + .story-second-page-section ol li { + margin-bottom: 10px; + list-style-position: inside; + } + + + + .story-company1-div{ + position: relative; + width: 210mm; + height: 296.7mm; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); + } + + .story-bg4{ + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); + } + + .story-heading{ + position: relative; + width: 100%; + text-align: left; + height: auto; + left: 50px; + top: 42px; + + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.2rem; + line-height: 29px; + + color: #FFFFFF; + + } + + + .story-contentBox1{ + position: relative; + width: auto; + height: auto; + left: 50px; + top: 106px; + + } + .story-tweet1-div{ + margin-top: 64px; + height: auto; + } + + .story-line1-logo1{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; + transition: top 0.3s ease; + } + + .story-tweet-box{ + box-sizing: border-box; + position: relative; + width: 640px; + left: 12px; + top: 20px; + background: #FFFFFF; + border: 2px solid #00A1F6; + border-radius: 12px; + display: flex; + max-height: 250px; + } + + + /* story third page */ + + .story-company2-div{ + position: relative; + width: 100%; + height: 297mm; + background: linear-gradient(0.1deg, #A6C9D5 0%, #FFFFFF 10%); + margin: 0; + padding: 0; + border: 1px solid transparent; + } + + .story-in-thirdpage { + margin: 0px 50px; + padding: 0; + } + + .story-contentBox{ + position: relative; + width: auto; + height: auto; + margin: 40px 0 0 0; + padding: 0; + text-align: justify; + + } + + .story-line1-logo{ + position: relative; + width: 110px; + height: 0px; + margin-top: 40px; + border: 2px solid #4192A6; + transition: top 0.3s ease; + } + + .story-heading-third{ + position: relative; + width: 100%; + text-align: left; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.2rem; + line-height: 29px; + color: #000000; + margin-top: 0 0 0 0; + } + + .story-line-logo1{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; + } + + .story-line-logo1-third{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; + } + + /* shikshalokam */ + .story-company4-div{ + position: relative; + width: 210mm; + height: 296.7mm; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); + } + + .story-shikshaLokam-heading { + font-family: 'Open Sans', sans-serif; + font-weight: 700; + font-size: 1.7rem; + color: #FFFFFF; + text-align: left; + } + + .story-shikshaLokam-contentBox{ + position: relative; + width: auto; + height: auto; + left: 50px; + top: 106px; + } + + .story-shikshaLokam-div { + position: relative; + background: linear-gradient(170deg, #D6D1D8 20%, #FFFFFF 50%); + width: 210mm; + height: 296.7mm; + } + + .story-shikshaLokam-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + width: calc(50% - 20px); + min-height: 450px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + box-sizing: border-box; + } + + .story-subCard{ + height: auto; + flex: none; + order: 2; + flex-grow: 0; + } + + .story-shikshaLokam-star{ + width: 28px; + height: 28.2px; + flex: none; + order: 0; + flex-grow: 0; + } + + .story-shikshaLokam-card-heading{ + width: auto; + height: 19px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.5rem; + line-height: 19px; + color: #3F8283; + flex: none; + order: 1; + flex-grow: 0; + } + + .shikshalokam-line-logo { + width: 160px; + height: 0px; + border: 2px solid #4192A6; + margin: 17px 2px 29px 2px; + } + + .story-fifthpage-wrapper { + padding: 33px 40px 20px 40px; + } + + .story-cards-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 20px; + margin-top: 30px; + } + + .story-shikshaLokam-card-content{ + width: auto; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + color: #000000; + flex: none; + flex-grow: 1; + order: 2; + line-height: 1.5rem; + text-align: left; + margin-bottom: 20px; + } + + .story-shikshaLokam-card-content1{ + width: auto; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + line-height: 18px; + color: #000000; + line-height: 1.5rem; + } + + .story-shikshaLokam-card-line{ + width: 256px; + height: 1px; + border: 3px solid #4192A6; + border-radius: 20px; + flex-shrink: 0; /* Prevent the line from shrinking */ + align-self: flex-start; + order: 3; + } + + .story-shikshaLokam-card1{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + /* left: 400px; + top: 176px; */ + left: 400px; + top: 606px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + + } + + .story-shikshaLokam-card2{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + left: 50px; + top: 606px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + } + + .story-shikshaLokam-card3{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + /* left: 400px; + top: 606px; */ + left: 400px; + top: 176px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + } + + .load-spinner{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; + } + + + .story-tweet-box3 { + box-sizing: border-box; + position: relative; + width: 640px; + left: 17px; + top: 80px; + background: #FFFFFF; + border: 2px solid #00A1F6; + border-radius: 12px; + display: flex; + flex-direction: column; + padding: 10px; + max-height: none; + overflow: hidden; + } + + .story-tweet3 { + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + line-height: 28px; + color: #000000; + } + + +@tailwind base; +@tailwind components; +@tailwind utilities; + +.top-to-btm{ + position: relative; + } + .icon-position{ + position: fixed; + bottom: 150px; + right: 25px; + z-index: 20; + } + .btm-icon-position{ + position: fixed; + top: 60px; + right: 25px; + z-index: 20; + } + .icon-style{ + background-color: #551B54; + border: 2px solid #fff; + border-radius: 50%; + height: 50px; + width: 50px; + color: #fff; + cursor: pointer; + animation: movebtn 3s ease-in-out infinite; + transition: all .5s ease-in-out; + } + .icon-style:hover{ + animation: none; + background: #fff; + color: #551B54; + border: 2px solid #551B54; + } + + @keyframes movebtn { + 0%{ + transform: translateY(0px); + } + 25%{ + transform: translateY(20px); + } + 50%{ + transform: translateY(0px); + } + 75%{ + transform: translateY(-20px); + } + 100%{ + transform: translateY(0px); + } + } + + :root { + --rt-color-white: #fff; + --rt-color-dark: #222; + --rt-color-success: #8dc572; + --rt-color-error: #be6464; + --rt-color-warning: #f0ad4e; + --rt-color-info: #337ab7; + --rt-opacity: 1; + --rt-transition-show-delay: 0.15s; + --rt-transition-closing-delay: 0.15s; + } + + .rotate-loader { + animation: rotate 1s linear infinite; + } + + @keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + +@media screen and (min-width: 992px) { + #scrollableDiv::-webkit-scrollbar { + width: 5px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-thumb { + background: #4192a6; + } +} + +@media screen and (min-width: 769px) and (max-width: 991px) { + #scrollableDiv::-webkit-scrollbar { + width: 5px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-thumb { + background: #4192a6; + } +} + +@media screen and (max-width: 768px) { + #scrollableDiv::-webkit-scrollbar { + width: 10px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: #4192a6; + } + +} + + +/* image page */ + + .story-image-page-container { + text-align: center; + padding-top: 20px; + } + + .story-image-page-title { + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2.5rem; + line-height: 0; + letter-spacing: normal; + color: #000; + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; + margin: 40px 0 70px 0; + } + + .story-image-page-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + justify-items: center; + grid-row-gap: 20px; + grid-column-gap: 0px; + } + + .image-nohead { + margin-top: 40px; + } + + .image-report { + width:100%; + height:100%; + border-radius: 10px; + } + + .story-image-page-image-box { + width: 330px; + height: 300px; + padding: 40px 0 0 0; + + } + + .story-img-split-div{ + background-color: #fff; + border: 2px solid #ccc; + border-radius: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 3px; + width: 100%; + height: 100%; + } + + .story-img-split-div img { + width: 100%; + height: 100%; + } + + .page-break { + page-break-before: always; + } + + .page-break-new { + page-break-before: always; + page-break-after: always; + page-break-inside: avoid; + break-before: page; /* Newer CSS spec */ + break-after: page; + break-inside: avoid; + } \ No newline at end of file diff --git a/chatbot/pdf/shiksha_chaupal/story_images_page.py b/chatbot/pdf/shiksha_chaupal/story_images_page.py new file mode 100644 index 0000000..ae20a81 --- /dev/null +++ b/chatbot/pdf/shiksha_chaupal/story_images_page.py @@ -0,0 +1,28 @@ +from chatbot.models import StoryMedia + + +def get_report_images_page_html(story): + + story_media = StoryMedia.objects.filter(story=story, include_in_story=True).exclude(media_type="pdf") + images = [media.get_public_url() for media in story_media] + image_elements = "" + page_html = "" + + for image in images: + image_elements += f""" +
    +
    + Story Image +
    +
    + """ + + page_html += f""" +
    +
    + {image_elements} +
    +
    + """ + + return page_html diff --git a/chatbot/pdf/story_first_page.py b/chatbot/pdf/story_first_page.py new file mode 100644 index 0000000..d4b63c4 --- /dev/null +++ b/chatbot/pdf/story_first_page.py @@ -0,0 +1,101 @@ +def get_first_page_html(profile, project, voice_provider, story, story_vernacular, flow): + profile_addresses=None + translation_json = story_vernacular.translation_json + if translation_json: + translation_json = translation_json.get('first_page', {}) + else: + translation_json = {} + + company_logo = translation_json.get('main_logo', '') + if profile and profile.first_name: + profile_addresses = profile.profile_address.all().first() + # company_logo = profile.company.get_public_url() + # else: + # company_logo = voice_provider.company_bot.company.get_public_url() + print("logo: ", company_logo) + current_state = profile_addresses.state if profile_addresses else "" + + address_string = story.other_params.get('location', '') if story.other_params else '' + if not address_string: + address_string = story.location if story.location else '' + + print("current_state: ", current_state) + if project: + title = project.get('expected_title') or project.get('actual_title') or "Improvement_story" + elif story: + title = story.title if story.title else "Improvement_story" + else: + title = "Improvement_story" + + author = story.other_params.get('user_name', '') if story.other_params else '' + + if profile_addresses and profile_addresses.state and profile_addresses.state.lower() == 'nagaland': + html = f""" +
    +
    +
    + Logo 1 + + Logo 2 + + Logo 3 +
    + +

    + {title} +

    +
    + pdf_bg1 + +
    +
    +

    {author}

    +

    {address_string}

    +
    +
    +
    + Logo 1 + + Logo 2 +
    +
    + """ + else: + html = f""" +
    +
    +
    +
    + Bottom Logo +
    +
    + +

    + {title} +

    +
    + pdf_bg1 + +
    +
    +

    {author}

    +

    {address_string}

    +
    +
    +
    + """ + return html diff --git a/chatbot/pdf/story_images_page.py b/chatbot/pdf/story_images_page.py new file mode 100644 index 0000000..fdf000c --- /dev/null +++ b/chatbot/pdf/story_images_page.py @@ -0,0 +1,39 @@ +from chatbot.models import StoryMedia + + +def get_story_images_page_html(story, story_vernacular): + + story_media = StoryMedia.objects.filter(story=story, include_in_story=True).exclude(media_type="pdf") + images = [media.get_public_url() for media in story_media] + image_elements = "" + image_batches = [images[i : i+6] for i in range(0, len(images), 6)] + page_html = "" + should_show_story_heading = True + + translation_json = story_vernacular.translation_json + if translation_json: + translation_json = translation_json.get('image_page', {}) + else: + translation_json = {} + image_heading = translation_json.get('heading1', "") + for batch in image_batches: + image_elements = "" + for image in batch: + image_elements += f""" +
    + Story Image +
    + """ + + page_html += f""" +
    + {f'

    {image_heading}

    ' if should_show_story_heading + else '
    '} +
    + {image_elements} +
    +
    + """ + should_show_story_heading = False + + return page_html diff --git a/chatbot/pdf/story_pdf.css b/chatbot/pdf/story_pdf.css new file mode 100644 index 0000000..b01bf94 --- /dev/null +++ b/chatbot/pdf/story_pdf.css @@ -0,0 +1,1028 @@ +/* story first page */ + +.story-company-div-fmt1 { + background-color: #f1f1f1; +} + +.nagaland-logo-div { + width: 100%; + margin-top: 40px; + display: flex; + flex-direction: row; + align-items: center; + gap: 40px; + justify-content: center; +} + +.nagaland-company-logo-div { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0 50px; +} + +.story-company-div1 { + position: relative; + width: 210mm; + height: 296.7mm; + background: #FFFFFF; + text-align: center; /* Centers logos horizontally */ +} + +.story-company-div { + position: relative; + width: 210mm; + height: 296.7mm; + background: #FFFFFF; +} + +html, body { + margin: 0; + padding: 0; + width: 100%; + min-height: 100%; + font-family: 'Open Sans', sans-serif; + background-color: #f1f1f1; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + + +.story-company-text-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2.5rem; + line-height: 29px; + letter-spacing: normal; + text-align: center; + color: #000; + margin: 46px 0 30px 0; + + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; +} + +.story-title-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 2rem; + text-align: center; + letter-spacing: normal; + color: #000; + line-height: 3.3rem; + margin: 10px 70px; + + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; +} + +.story-nagaland-logo-div { + display: flex; + flex-direction: column; + margin: 0; +} + +.nagaland-image-div { + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.story-bg1-fmt1{ + max-width: 495px; + height: auto; + margin: 46px 0 25px 0; +} + +.story-logo-fmt1{ + max-width: 119px; + height: auto; + padding: 10px; +} + +.story-logo1-fmt1{ + max-width: 182px; + height: auto; +} + +.story-logo2-fmt1{ + max-width: 195px; + height: auto; +} + +.story-firstpage-bottom-div { + display: flex; + justify-content: center; + align-items: center; + width: 89%; + margin: 51px auto; +} + +.story-firstpage-bottom-in-div { + display: flex; + justify-content: space-evenly; + align-items: center; + width: 100%; + background: #FEFCF3; + padding: 15px 0; +} + +.story-logo3-fmt1{ + max-width: 107px; + height: auto; +} + +.story-logo3-fmt1-normal { + max-width: 132px; + height: auto; +} + +.story-author-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 1.7rem; + line-height: 2rem; + text-align: center; + color: #000; + margin: 20px 0; +} + +.story-school-fmt1{ + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 400; + font-size: 1.7rem; + line-height: 19px; + text-align: center; + color: #000; + margin: 0; +} + +.story-normal-state-div { + display: flex; + align-items: center; + justify-content: space-evenly; + margin: 0 auto; +} + +.story-logo-div{ + position: absolute; + width: 90%; + top: 5%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.story-logo-div{ + position: absolute; + width: 100%; + top: 5%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.story-logo-div1{ + position: absolute; + width: 100%; + height: 10%; + top: 5%; + display: flex; + justify-content: center; + align-items: center; +} + +.story-company-logo-First{ + position: relative; + width: 400px; + height: auto; + display: inline; +} + +.story-company-logo{ + position: relative; + max-width: none; + right: 290px; + bottom: 51px; + width: 736px; + height: auto; + display: inline; +} + +.story-company-logo1 { + position: relative; + max-width: none; + margin-right: 10px; + width: 122px; + height: auto; + display: inline-block; +} + +.story-shikshalokam-logo { + position: relative; + max-width: none; + width: 227px; + height: auto; + display: inline-block; + padding: 10px; +} + +.story-shikshalokam-logo-normal { + position: relative; + max-width: none; + margin-right: 19px; + width: 281px; + height: auto; + display: inline-block; +} + +.story-govt-logo { + position: relative; + max-width: none; + width: 83px; + height: auto; + display: inline-block; + padding: 10px; +} + +.story-haryana-logo { + position: relative; + max-width: none; + width: 125px; + height: auto; + display: inline-block; +} + + +.story-bg0{ + position: absolute; + width: 100%; + height: 100%; + left: 0px; + background: linear-gradient(0.38deg, #4192A6 9.33%, rgba(65, 146, 166, 0) 58.17%); +} + +.story-bg1{ + position: absolute; + width: 588px; + height: 622px; + left: calc(50% - 588px/2 + 0.5px); + top: calc(50% - 622px/2 - 33px); +} + +.story-bg2{ + position: absolute; + width: 470px; + height: 513px; + left: calc(50% - 470px/2 + 0.5px); + top: calc(50% - 513px/2 - 35.5px); +} + +.story-title{ + position: relative; + height: auto; + top: 898px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 23px; + text-align: center; + color: #FFFFFF; + margin: 0; +} + +.story-author{ + position: relative; + height: auto; + top: 931px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 20px; + text-align: center; + color: #FFFFFF; + margin: 0; +} + +.story-link{ + position: relative; + display: block; + height: auto; + top: 956px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 16px; + text-align: center; + text-decoration-line: underline; + color: #FFFFFF; + margin: 0; +} + +.story-link1{ + position: relative; + width: 122px; + height: auto; + top: 956px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 16px; + text-align: center; + text-decoration-line: underline; + color: #FFFFFF; +} + +.story-details-div { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + align-items: center; +} + +/* story second page */ + +.story-second-page-container { + width: 100%; + background-color: #f1f1f1; + padding: 20px 40px 20px 40px; + box-sizing: border-box; + page-break-before: always; +} + +.empty-page-break { + page-break-before: always; + display: none; /* Prevent it from taking visible space */ + height: 0; +} + +.story-second-page-container h1 { + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2rem; + line-height: 0; + letter-spacing: normal; + color: #000; + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; + margin: 20px 0 40px 0; + text-align: center; +} + +.story-second-page-section { + margin-top: 15px; + margin-bottom: 15px; + padding: 20px; + border: 2px solid #ddd; + border-radius: 20px; + background-color: #fff; + page-break-inside: avoid; + break-inside: avoid; +} + +/* Overflow pages for action steps that didn't fit on the second page */ +.story-action-steps-overflow { + padding-top: 40px; + padding-bottom: 40px; +} + +/* Standalone Impact wrapper — always placed outside action-step containers */ +.story-impact-wrapper { + background-color: #f1f1f1; + padding: 30px 40px 40px 40px; + box-sizing: border-box; +} + +.story-impact-wrapper .story-second-page-section { + margin-top: 0; +} + +.story-second-page-section h2 { + display: flex; + align-items: center; + font-size: 1.2rem; + color: #3a8479; + margin-bottom: 10px; +} + +.story-second-page-section h2::before { + content: "★"; + color: #3a8479; + font-size: 20px; + margin-right: 10px; +} + +.story-second-page-section p, .story-second-page-section ol { + font-size: 1rem; + color: #333; + margin: 0; +} +.story-second-page-section ol { + padding-left: 20px; + margin-top: 10px; +} +.story-second-page-section ol li { + margin-bottom: 10px; + list-style-position: inside; + page-break-inside: avoid; + break-inside: avoid; +} + + + +.story-company1-div{ + position: relative; + width: 210mm; + height: 296.7mm; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); +} + +.story-bg4{ + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); +} + +.story-heading{ + position: relative; + width: 100%; + text-align: left; + height: auto; + left: 50px; + top: 42px; + + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.2rem; + line-height: 29px; + + color: #FFFFFF; + +} + + +.story-contentBox1{ + position: relative; + width: auto; + height: auto; + left: 50px; + top: 106px; + +} +.story-tweet1-div{ + margin-top: 64px; + height: auto; +} + +.story-line1-logo1{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; + transition: top 0.3s ease; +} + +.story-tweet-box{ + box-sizing: border-box; + position: relative; + width: 640px; + left: 12px; + top: 20px; + background: #FFFFFF; + border: 2px solid #00A1F6; + border-radius: 12px; + display: flex; + max-height: 250px; +} + + +/* story third page */ + +.story-company2-div{ + position: relative; + width: 100%; + height: 297mm; + background: linear-gradient(0.1deg, #A6C9D5 0%, #FFFFFF 10%); + margin: 0; + padding: 0; + border: 1px solid transparent; +} + +.story-in-thirdpage { + margin: 0px 50px; + padding: 0; +} + +.story-contentBox{ + position: relative; + width: auto; + height: auto; + margin: 40px 0 0 0; + padding: 0; + text-align: justify; + + page-break-inside: avoid; + break-inside: avoid; + +} + +.story-line1-logo{ + position: relative; + width: 110px; + height: 0px; + margin-top: 40px; + border: 2px solid #4192A6; + transition: top 0.3s ease; +} + +.story-heading-third{ + position: relative; + width: 100%; + text-align: left; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.2rem; + line-height: 29px; + color: #000000; + margin-top: 0 0 0 0; +} + +.story-line-logo1{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; +} + +.story-line-logo1-third{ + position: relative; + width: 110px; + height: 0px; + border: 2px solid #4192A6; +} + +/* shikshalokam */ +.story-company4-div{ + position: relative; + width: 210mm; + height: 296.7mm; + background: linear-gradient(180deg, #EA9C3F 2.21%, #FFFFFF 50.6%); +} + +.story-shikshaLokam-heading { + font-family: 'Open Sans', sans-serif; + font-weight: 700; + font-size: 1.7rem; + color: #FFFFFF; + text-align: left; +} + +.story-shikshaLokam-contentBox{ + position: relative; + width: auto; + height: auto; + left: 50px; + top: 106px; +} + +.story-shikshaLokam-div { + position: relative; + background: linear-gradient(170deg, #D6D1D8 20%, #FFFFFF 50%); + width: 210mm; + height: 296.7mm; +} + +.story-shikshaLokam-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + width: calc(50% - 20px); + min-height: 450px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + box-sizing: border-box; +} + +.story-subCard{ + height: auto; + flex: none; + order: 2; + flex-grow: 0; +} + +.story-shikshaLokam-star{ + width: 28px; + height: 28.2px; + flex: none; + order: 0; + flex-grow: 0; +} + +.story-shikshaLokam-card-heading{ + width: auto; + height: 19px; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 700; + font-size: 1.5rem; + line-height: 19px; + color: #3F8283; + flex: none; + order: 1; + flex-grow: 0; +} + +.shikshalokam-line-logo { + width: 160px; + height: 0px; + border: 2px solid #4192A6; + margin: 17px 2px 29px 2px; +} + +.story-fifthpage-wrapper { + padding: 33px 40px 20px 40px; +} + +.story-cards-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 20px; + margin-top: 30px; +} + +.story-shikshaLokam-card-content{ + width: auto; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + color: #000000; + flex: none; + flex-grow: 1; + order: 2; + line-height: 1.5rem; + text-align: left; + margin-bottom: 20px; +} + +.story-shikshaLokam-card-content1{ + width: auto; + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + line-height: 18px; + color: #000000; + line-height: 1.5rem; +} + +.story-shikshaLokam-card-line{ + width: 256px; + height: 1px; + border: 3px solid #4192A6; + border-radius: 20px; + flex-shrink: 0; /* Prevent the line from shrinking */ + align-self: flex-start; + order: 3; +} + +.story-shikshaLokam-card1{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + /* left: 400px; + top: 176px; */ + left: 400px; + top: 606px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; + +} + +.story-shikshaLokam-card2{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + left: 50px; + top: 606px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; +} + +.story-shikshaLokam-card3{ + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 28px 15px; + gap: 23px; + position: absolute; + width: 295px; + height: auto; + min-height: 369.21px; + /* left: 400px; + top: 606px; */ + left: 400px; + top: 176px; + background: #FFFFFF; + border: 2.4px solid rgba(0, 0, 0, 0.25); + border-radius: 12px; +} + +.load-spinner{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; +} + + +.story-tweet-box3 { + box-sizing: border-box; + position: relative; + width: 640px; + left: 17px; + top: 80px; + background: #FFFFFF; + border: 2px solid #00A1F6; + border-radius: 12px; + display: flex; + flex-direction: column; + padding: 10px; + max-height: none; + overflow: hidden; +} + +.story-tweet3 { + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 300; + font-size: 1rem; + line-height: 28px; + color: #000000; +} + + +@tailwind base; +@tailwind components; +@tailwind utilities; + +.top-to-btm{ + position: relative; +} +.icon-position{ + position: fixed; + bottom: 150px; + right: 25px; + z-index: 20; +} +.btm-icon-position{ + position: fixed; + top: 60px; + right: 25px; + z-index: 20; +} +.icon-style{ + background-color: #551B54; + border: 2px solid #fff; + border-radius: 50%; + height: 50px; + width: 50px; + color: #fff; + cursor: pointer; + animation: movebtn 3s ease-in-out infinite; + transition: all .5s ease-in-out; +} +.icon-style:hover{ + animation: none; + background: #fff; + color: #551B54; + border: 2px solid #551B54; +} + +@keyframes movebtn { + 0%{ + transform: translateY(0px); + } + 25%{ + transform: translateY(20px); + } + 50%{ + transform: translateY(0px); + } + 75%{ + transform: translateY(-20px); + } + 100%{ + transform: translateY(0px); + } +} + +:root { + --rt-color-white: #fff; + --rt-color-dark: #222; + --rt-color-success: #8dc572; + --rt-color-error: #be6464; + --rt-color-warning: #f0ad4e; + --rt-color-info: #337ab7; + --rt-opacity: 1; + --rt-transition-show-delay: 0.15s; + --rt-transition-closing-delay: 0.15s; +} + +.rotate-loader { + animation: rotate 1s linear infinite; +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media screen and (min-width: 992px) { + #scrollableDiv::-webkit-scrollbar { + width: 5px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-thumb { + background: #4192a6; + } +} + +@media screen and (min-width: 769px) and (max-width: 991px) { + #scrollableDiv::-webkit-scrollbar { + width: 5px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: transparent; + } + + #scrollableDiv:hover::-webkit-scrollbar-thumb { + background: #4192a6; + } +} + +@media screen and (max-width: 768px) { + #scrollableDiv::-webkit-scrollbar { + width: 10px; + position: absolute; + left: 0; + } + + #scrollableDiv::-webkit-scrollbar-track { + background: #b5b7b7; + } + + #scrollableDiv::-webkit-scrollbar-thumb { + background: #4192a6; + } + +} + + +/* image page */ + +.story-image-page-container { + text-align: center; +} + +.story-image-page-title { + height: auto; + font-family: 'Open Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 2.5rem; + line-height: 0; + letter-spacing: normal; + color: #000; + text-decoration: underline; + text-decoration-color: #4192A6; + text-decoration-thickness: 2px; + text-underline-offset: 16px; + margin: 40px 0 70px 0; +} + +.story-image-page-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + justify-items: center; + grid-row-gap: 20px; + grid-column-gap: 0px; + + /* break-inside: avoid; */ +} + +.image-nohead { + margin-top: 40px; +} + +.story-image-page-image-box { + width: 330px; + height: 300px; + background-color: #fff; + border: 2px solid #ccc; + border-radius: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 3px; + + /* page-break-inside: avoid; + break-inside: avoid; */ + +} + +.story-image-page-image-box img { + width: 100%; + height: 100%; +} + +.page-break { + page-break-before: always; +} + +.page-break-new { + page-break-before: always; + page-break-after: always; + page-break-inside: avoid; + break-before: page; /* Newer CSS spec */ + break-after: page; + break-inside: avoid; +} \ No newline at end of file diff --git a/chatbot/pdf/story_secondpage.py b/chatbot/pdf/story_secondpage.py new file mode 100644 index 0000000..d6c085b --- /dev/null +++ b/chatbot/pdf/story_secondpage.py @@ -0,0 +1,200 @@ +import re +import json_repair + + +def _split_steps_by_char_limit(steps, first_page_limit, full_page_limit): + """Split a list of action-step strings into page-sized batches. + """ + if not steps: + return [] + + batches = [] + current_batch = [] + current_chars = 0 + limit = first_page_limit # first batch uses the smaller limit + + for step in steps: + step_len = len(step) + if current_batch and current_chars + step_len > limit: + # current batch is full – start a new one + batches.append(current_batch) + current_batch = [step] + current_chars = step_len + limit = full_page_limit # subsequent pages get the larger limit + else: + current_batch.append(step) + current_chars += step_len + + if current_batch: + batches.append(current_batch) + + return batches + + +def _build_steps_ol(steps, start_index=1): + """Return an
      HTML string for the given steps, starting numbering at *start_index*.""" + return ( + f"
        " + + ''.join(f"
      1. {step}
      2. " for step in steps) + + "
      " + ) + + +def get_story_secondpage_html(story, project, story_vernacular): + print("story.action_steps: ", story.action_steps) + translation_json = story_vernacular.translation_json + if translation_json: + translation_json = translation_json.get('second_page', {}) + else: + translation_json = {} + + second_page_action_steps_char_limit = translation_json.get('SECOND_PAGE_ACTION_STEPS_CHAR_LIMIT', 950) + full_page_action_steps_char_limit = translation_json.get('FULL_PAGE_ACTION_STEPS_CHAR_LIMIT', 2200) + second_page_total_char_limit = translation_json.get('SECOND_PAGE_TOTAL_CHAR_LIMIT', 1500) + + if isinstance(story.action_steps, str): + try: + if story.action_steps.strip().startswith("["): + story.action_steps = json_repair.repair_json(story.action_steps, return_objects=True) + print("story.action_steps after repair: ", story.action_steps) + else: + story.action_steps = [story.action_steps] + except Exception as e: + print(f"Error repairing JSON: {e}") + story.action_steps = [translation_json.get('no_action_step_text', "")] + + action_steps = ( + [clean_escaped_text(step) for step in story.action_steps] if isinstance(story.action_steps, list) + else [clean_escaped_text(story.action_steps)] if isinstance(story.action_steps, str) + else [translation_json.get('no_action_step_text', "")] + ) + print("action step type: ", type(action_steps)) + print("action_steps: ", action_steps) + # steps = action_steps[0] + if action_steps and isinstance(action_steps, list) and len(action_steps) == 1 and isinstance(action_steps[0], str): + steps_text = action_steps[0] + split_steps = re.findall(r'\d+\.\s*[^0-9]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + if not split_steps: + split_steps = action_steps + elif action_steps and isinstance(action_steps, str): + steps_text = " ".join(action_steps) + split_steps = re.findall(r'\d+\.\s*[^.]+', steps_text) + split_steps = [step.strip() for step in split_steps if step.strip()] + else: + split_steps = [step.strip() for step in action_steps if step.strip()] + print("\n\nsplit_steps: ", split_steps) + + # ── Split action steps into page-sized batches ─────────────────────── + step_batches = _split_steps_by_char_limit( + split_steps, + first_page_limit=second_page_action_steps_char_limit, + full_page_limit=full_page_action_steps_char_limit, + ) + + # Build HTML for the first batch (shown on the second page) + if step_batches: + first_batch_html = _build_steps_ol(step_batches[0], start_index=1) + else: + first_batch_html = None + + print("\n\nfirst_batch steps_html: ", first_batch_html) + print("story.objective: ", story.objective) + if project: + problem_statement = project.get('actual_problem_statement', '') + elif story: + problem_statement = story.other_params.get('problem_statement', '') if story and story.other_params else '' + else: + problem_statement = '' + + problem_statement = capitalize_first_letter(problem_statement) + story.objective = capitalize_first_letter(story.objective or translation_json.get('no_objective_text', "")) + story.impact = capitalize_first_letter(story.impact or translation_json.get('no_impact_text', "")) + print("translation_json: ", translation_json) + print("problem_statement:", repr(problem_statement)) # Use repr() to see if it's empty string or None + print("Fallback text:", repr(translation_json.get('no_problem_statement_text', ""))) + print("Final result:", repr(problem_statement or translation_json.get('no_problem_statement_text', ""))) + + + impact_section = f""" +
      +

      {translation_json.get('heading5', "")}

      +

      {story.impact or translation_json.get('no_impact_text', "")}

      +
      """ + + impact_standalone = f""" +
      + {impact_section} +
      + """ + + + overflow_batches = step_batches[1:] if step_batches else [] + has_overflow = len(overflow_batches) > 0 + + ps_text = problem_statement or translation_json.get('no_problem_statement_text', '') + obj_text = story.objective or translation_json.get('no_objective_text', '') + impact_text = story.impact or translation_json.get('no_impact_text', '') + first_batch_chars = sum(len(s) for s in step_batches[0]) if step_batches else 0 + impact_chars = len(impact_text) + second_page_chars = len(ps_text) + len(obj_text) + first_batch_chars + impact_chars + print(f"second_page_chars (incl. impact): {second_page_chars}, limit: {second_page_total_char_limit}") + + # Impact goes inline only when there's no overflow AND all 4 sections fit + impact_fits_on_page = not has_overflow and second_page_chars <= second_page_total_char_limit + + page_html = f""" +
      +

      {translation_json.get('heading1', "")}

      +
      +

      {translation_json.get('heading2', "")}

      +

      {ps_text}

      +
      +
      +

      {translation_json.get('heading3', "")}

      +

      {obj_text}

      +
      +
      +

      {translation_json.get('heading4', "")}

      + {first_batch_html or translation_json.get('no_action_step_text', "")} +
      + {impact_section if impact_fits_on_page else ''} +
      + """ + + + running_index = len(step_batches[0]) + 1 if step_batches else 1 + for batch in overflow_batches: + overflow_ol = _build_steps_ol(batch, start_index=running_index) + page_html += f""" +
      +
      + {overflow_ol} +
      +
      + """ + running_index += len(batch) + + + if not impact_fits_on_page: + page_html += impact_standalone + + return page_html + + +def clean_escaped_text(text): + text = text.replace("\\'", "")# \' → ' + text = text.replace('\\"', '')# \" → " + text = text.replace("\\\\", "") # \\ → \ + print("Text: ", text) + return text + + +def capitalize_first_letter(text): + """Capitalize the first alphabetical character in the string, safely.""" + if not text: + return text + text = text.lstrip() + if not text: + return text + return text[0].upper() + text[1:] diff --git a/chatbot/pdf/story_thirdpage.py b/chatbot/pdf/story_thirdpage.py new file mode 100644 index 0000000..58825c7 --- /dev/null +++ b/chatbot/pdf/story_thirdpage.py @@ -0,0 +1,149 @@ +import json +import re + +from bs4 import BeautifulSoup + +from chatbot.models import SessionFlowName +from chatbot.utils.story_llama_utils import translate_field + + +def json_to_html(formatted_content): + + try: + content_data = json.loads(formatted_content) + print("type: ", type(content_data)) + except json.JSONDecodeError: + return "" + + html_content = "" + for block in content_data: + print("block type: ", type(block)) + if isinstance(block, dict) and "type" in block and "data" in block: + if block["type"] == "paragraph": + text = block["data"].get("text", "") + + text = text.replace("\n\n", "

      ").replace("\n", "
      ") + + html_content += f"

      {text}

      " + else: + print(f"Unexpected block format: {block}") + + return html_content + + +def count_words_and_lines(text): + + word_count = 0 + lines = text.splitlines() + + for line in lines: + word_count += len(line.split()) + + return word_count, len(lines) + + +def split_content_based_on_words(content, max_words_per_page=400): + soup = BeautifulSoup(content, "html.parser") + chunks = [] + current_chunk = [] + word_counter = 0 + + for element in soup.find_all(["p", "br"]): + if element.name == "p": + text = element.decode_contents() + words = text.split() + paragraph_word_count = len(words) + i = 0 + + while i < paragraph_word_count: + remaining_space = max_words_per_page - word_counter + if paragraph_word_count - i > remaining_space: + # Take a chunk of words that fit on the current page + part = " ".join(words[i : i + remaining_space]) + current_chunk.append(f"

      {part}

      ") + chunks.append("".join(current_chunk)) + current_chunk = [] + word_counter = 0 + i += remaining_space + else: + # The remaining words fit within the current page + part = " ".join(words[i:]) + current_chunk.append(f"

      {part}

      ") + word_counter += paragraph_word_count - i + break + + elif element.name == "br" and current_chunk: + current_chunk.append("
      ") + + if current_chunk: + chunks.append("".join(current_chunk)) + + return chunks + + +def get_thirdpage_html(profile, story, project, voice_provider, story_vernacular, flow): + profile_addresses=None + # if profile and profile.first_name: + # profile_addresses = profile.profile_address.all().first() + + # if flow and flow in [SessionFlowName.GuestMiStory]: + # address_string = story.other_params.get('location', '') if story.other_params else '' + # else: + # address_components = [ + # profile_addresses.district if profile_addresses and profile_addresses.district else "", + # profile_addresses.block if profile_addresses and profile_addresses.block else "", + # profile_addresses.state if profile_addresses and profile_addresses.state else "" + # ] + # address_string = ", ".join(filter(None, address_components)) + + # author = story.other_params.get('user_name', '') if story.other_params else '' + + sanitized_content = json_to_html(story.formatted_content) + should_show_story_heading = True + + content_chunks = split_content_based_on_words(sanitized_content) + + # if project and project.project_language and project.project_language != 'en': + # if address_string: + # address_string = translate_field( + # voice_provider=voice_provider, message_body=address_string, target_language=project.project_language + # ) + + # translation_json = story_vernacular.translation_json + # if translation_json: + # translation_json = translation_json.get('third_page', {}) + # else: + # translation_json = {} + + # if profile and profile.first_name: + # author_title = translation_json.get('title', '') + # else: + # author_title = translation_json.get('title1', '') + + title = ( + f"{story.title or ''}" + ) + + html_pages = [] + for chunk in content_chunks: + html_page = f""" +
      +
      + {f'

      {title}

      ' if should_show_story_heading else ''} + {(f'line_story') if should_show_story_heading else ''} + +
      +
      + {chunk} +
      + +
      +
      +
      + """ + html_pages.append(html_page) + should_show_story_heading = False + + return "\n".join(html_pages) diff --git a/chatbot/pompts/__init__.py b/chatbot/pompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/resources/__init__.py b/chatbot/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/resources/bot_resource.py b/chatbot/resources/bot_resource.py new file mode 100644 index 0000000..ebcc27e --- /dev/null +++ b/chatbot/resources/bot_resource.py @@ -0,0 +1,176 @@ +import json +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget +from chatbot.models import CompanyBot, Voice, CompanyStateMachine, Company + + +class CompanyBotResource(resources.ModelResource): + """Resource for exporting/importing CompanyBot with related Voice and StateMachine models""" + + # Use ForeignKeyWidget to handle company relationship + company = fields.Field( + column_name='company', + attribute='company', + widget=ForeignKeyWidget(Company, field='slug') + ) + + # JSON fields to store related models + voices = fields.Field(column_name='voices', attribute='voices') + state_machines = fields.Field(column_name='state_machines', attribute='state_machines') + + class Meta: + model = CompanyBot + fields = ( + 'id', 'name', 'company', 'context', 'max_token', 'provider', 'provider_keys', + 'bot_temperature', 'top_k', 'llm_model', 'filter_score', 'end_context', + 'introductory_message', 'tag_context', 'route', 'bot_type', 'llm_key', + 'dynamic_context', 'dynamic_context_type', 'pre_context', 'tool_context', + 'other_params', 'connect_timeout', 'read_timeout', 'voices', 'state_machines', + ) + export_order = fields + skip_unchanged = True + report_skipped = True + + def dehydrate_voices(self, bot): + """Export Voice objects related to this bot""" + voices = Voice.objects.filter(company_bot=bot) + voice_data = [] + for voice in voices: + voice_dict = { + 'type': voice.type, + 'provider': voice.provider, + 'name': voice.name, + 'sample_link': voice.sample_link, + 'language': voice.language, + 'provider_code': voice.provider_code, + 'gender': voice.gender, + 'voice_speed': voice.voice_speed, + 'other_params': voice.other_params + } + voice_data.append(voice_dict) + return json.dumps(voice_data) + + def dehydrate_state_machines(self, bot): + """Export CompanyStateMachine objects related to this bot""" + state_machines = CompanyStateMachine.objects.filter(company_bot=bot).order_by('step') + sm_data = [] + for sm in state_machines: + sm_dict = { + 'name': sm.name, + 'step': sm.step, + 'use_stage_chats': sm.use_stage_chats, + 'type': sm.type, + 'text_conversion_type': sm.text_conversion_type, + 'bot_question': sm.bot_question, + 'completion_criteria': sm.completion_criteria, + 'context': sm.context, + 'tool_context': sm.tool_context, + 'preprocess_type': sm.preprocess_type, + 'preprocess_prompt': sm.preprocess_prompt, + 'preprocess_bot_name': sm.preprocess_bot.name if sm.preprocess_bot else None, + 'preprocess_output_mode': sm.preprocess_output_mode, + 'postprocess_type': sm.postprocess_type, + 'postprocess_prompt': sm.postprocess_prompt, + 'postprocess_bot_name': sm.postprocess_bot.name if sm.postprocess_bot else None, + 'postprocess_output_mode': sm.postprocess_output_mode, + 'skip_to_step': sm.skip_to_step, + } + sm_data.append(sm_dict) + return json.dumps(sm_data) + + def before_import_row(self, row, **kwargs): + """Clean up the row data before import""" + # Remove empty strings and convert to None + for field in row: + if row[field] == '': + row[field] = None + + def after_import_instance(self, instance, new, **kwargs): + """Handle related models after the main instance is imported""" + row = kwargs.get('row', {}) + + # Import Voice objects + if 'voices' in row and row['voices']: + try: + voices_data = json.loads(row['voices']) + # Delete existing voices if updating + if not new: + Voice.objects.filter(company_bot=instance).delete() + + for voice_dict in voices_data: + # Skip if essential fields are missing + if not voice_dict.get('type') or not voice_dict.get('provider'): + continue + + Voice.objects.create( + company_bot=instance, + type=voice_dict.get('type'), + provider=voice_dict.get('provider'), + name=voice_dict.get('name'), + sample_link=voice_dict.get('sample_link'), + language=voice_dict.get('language'), + provider_code=voice_dict.get('provider_code'), + gender=voice_dict.get('gender', 'MALE'), + voice_speed=voice_dict.get('voice_speed', 1.0), + other_params=voice_dict.get('other_params') + ) + except (json.JSONDecodeError, KeyError) as e: + pass # Skip if JSON is invalid + + # Import CompanyStateMachine objects + if 'state_machines' in row and row['state_machines']: + try: + sm_data = json.loads(row['state_machines']) + # Delete existing state machines if updating + if not new: + CompanyStateMachine.objects.filter(company_bot=instance).delete() + + for sm_dict in sm_data: + # Skip if essential fields are missing + if not sm_dict.get('name') or sm_dict.get('step') is None: + continue + + # Handle preprocess and postprocess bot references + preprocess_bot = None + if sm_dict.get('preprocess_bot_name'): + try: + preprocess_bot = CompanyBot.objects.get( + name=sm_dict['preprocess_bot_name'], + company=instance.company + ) + except CompanyBot.DoesNotExist: + pass + + postprocess_bot = None + if sm_dict.get('postprocess_bot_name'): + try: + postprocess_bot = CompanyBot.objects.get( + name=sm_dict['postprocess_bot_name'], + company=instance.company + ) + except CompanyBot.DoesNotExist: + pass + + CompanyStateMachine.objects.create( + company_bot=instance, + name=sm_dict.get('name'), + step=sm_dict.get('step'), + use_stage_chats=sm_dict.get('use_stage_chats', False), + type=sm_dict.get('type', 'mandatory'), + text_conversion_type=sm_dict.get('text_conversion_type', 'translate'), + bot_question=sm_dict.get('bot_question'), + completion_criteria=sm_dict.get('completion_criteria'), + context=sm_dict.get('context'), + tool_context=sm_dict.get('tool_context'), + preprocess_type=sm_dict.get('preprocess_type', 'none'), + preprocess_prompt=sm_dict.get('preprocess_prompt'), + preprocess_bot=preprocess_bot, + preprocess_output_mode=sm_dict.get('preprocess_output_mode', 'none'), + postprocess_type=sm_dict.get('postprocess_type', 'none'), + postprocess_prompt=sm_dict.get('postprocess_prompt'), + postprocess_bot=postprocess_bot, + postprocess_output_mode=sm_dict.get('postprocess_output_mode', 'none'), + skip_to_step=sm_dict.get('skip_to_step'), + ) + except (json.JSONDecodeError, KeyError) as e: + pass # Skip if JSON is invalid \ No newline at end of file diff --git a/chatbot/resources/company_resource.py b/chatbot/resources/company_resource.py new file mode 100644 index 0000000..ed3632b --- /dev/null +++ b/chatbot/resources/company_resource.py @@ -0,0 +1,25 @@ +from import_export.resources import ModelResource +from import_export.fields import Field +from chatbot.models import ChatSession, CompanyChat + + +class ChatSessionResource(ModelResource): + transcription = Field(attribute='transcription', column_name='Transcription') + + class Meta: + model = ChatSession + fields = ( + 'transcription', + ) + + def dehydrate_transcription(self, obj): + chats = CompanyChat.objects.filter(session=obj.session).order_by('created_at') + if not chats.exists(): + return "-" + formatted_chats = [ + (f'{chat.created_at.strftime("%Y-%m-%d %H:%M")} - {chat.sender.first_name if chat.sender else "System"}: ' + f'{chat.message}') + for chat in chats + ] + return "\n\n".join(formatted_chats) + " | " + diff --git a/chatbot/resources/resource.py b/chatbot/resources/resource.py new file mode 100644 index 0000000..8223ba9 --- /dev/null +++ b/chatbot/resources/resource.py @@ -0,0 +1,111 @@ +from import_export.resources import ModelResource +from import_export.fields import Field +from import_export.widgets import ForeignKeyWidget +from chatbot.models import CompanyChat, Profile, Company +from chatbot.models.geo_models import ProfileAddress +from chatbot.models.media_models import ProfileMedia + + +class CompanyChatResource(ModelResource): + sender_name = Field(attribute='sender__first_name', column_name='Sender Name') + receiver_name = Field(attribute='receiver__first_name', column_name='Receiver Name') + sender_phone = Field(attribute='sender__phone', column_name='Sender Phone') + receiver_phone = Field(attribute='receiver__phone', column_name='Receiver Phone') + + class Meta: + model = CompanyChat + fields = ('message', 'sender_name', 'receiver_name', 'sender_phone', 'receiver_phone', + 'session', 'feedback') + + +class ProfileResource(ModelResource): + # company_name = Field(column_name='Company Name', + # attribute='company', + # widget=ForeignKeyWidget(Company, 'slug')) + id = Field(attribute='id', column_name='ID') + company = Field(attribute='company', column_name='company_id', widget=ForeignKeyWidget(Company, 'id')) + customer_name = Field(attribute='first_name', column_name='Customer Name') + org_associated = Field(attribute='org_associated', column_name='Organization Associated') + contact_number = Field(attribute='phone', column_name='Contact Number') + password = Field(attribute='password', column_name='Password') + profile_code = Field(attribute='profile_code', column_name='Profile Code') + email = Field(attribute='email', column_name='Email') + city = Field(attribute='get_city', column_name='City') + pin_code = Field(attribute='get_pin_code', column_name='PIN Code') + state = Field(attribute='get_state', column_name='State') + discussion_details = Field(attribute='get_discussion_details', column_name='Discussion Details') + company_spoc = Field(attribute='company_spoc', column_name='Company SPOC') + + class Meta: + model = Profile + fields = ('id', 'customer_name', 'org_associated', 'contact_number', 'email', 'city', 'pin_code', + 'state', 'enquiry_status', 'discussion_details', 'other_parameters', + 'company_spoc', 'company', 'password', 'profile_code') + import_id_fields = ('email',) # Use email as a unique identifier + + + def dehydrate_model_name(self, profile): + if not profile.pk: + return '' + return profile.other_params.get('model_name', '') if profile.other_params else '' + + def dehydrate_discussion_details(self, profile): + if not profile.pk: + return '' + return profile.other_params.get('discussion_details', '') if profile.other_params else '' + + + def dehydrate_state(self, profile): + if not profile.pk: + return '' + profile_address = ProfileAddress.objects.filter(profile=profile) + if len(profile_address) > 0 and profile_address[0].state: + return profile_address[0].state + else: + return '' + + def dehydrate_city(self, profile): + if not profile.pk: + return '' + profile_address = ProfileAddress.objects.filter(profile=profile) + if len(profile_address) > 0 and profile_address[0].city: + return profile_address[0].city + else: + return '' + + def dehydrate_pin_code(self, profile): + if not profile.pk: + return '' + profile_address = ProfileAddress.objects.filter(profile=profile) + if len(profile_address) > 0 and profile_address[0].pincode: + return profile_address[0].pincode + else: + return '' + + + def get_model_name(self, profile): + return profile.other_params.get('model_name', '') if profile.other_params else '' + + def get_discussion_details(self, profile): + return profile.other_params.get('discussion_details', '') if profile.other_params else '' + + def get_state(self, obj): + profile_address = ProfileAddress.objects.filter(profile=obj) + if len(profile_address) > 0 and profile_address[0].state: + return profile_address[0].state + else: + return '' + + def get_city(self, obj): + profile_address = ProfileAddress.objects.filter(profile=obj) + if len(profile_address) > 0 and profile_address[0].city: + return profile_address[0].city + else: + return '' + + def get_pin_code(self, profile): + profile_address = ProfileAddress.objects.filter(profile=profile) + if len(profile_address) > 0 and profile_address[0].pincode: + return profile_address[0].pincode + else: + return '' diff --git a/chatbot/resources/story_resource.py b/chatbot/resources/story_resource.py new file mode 100644 index 0000000..b313704 --- /dev/null +++ b/chatbot/resources/story_resource.py @@ -0,0 +1,182 @@ +import io +import zipfile +import requests +from django.utils.text import slugify +from django.http import HttpResponseRedirect +from django.contrib import admin +from django.utils.http import urlencode +from io import BytesIO +from django.utils.timezone import localtime +from docx import Document +from django.http import HttpResponse +from django.forms.models import model_to_dict +from docx.shared import Inches +import tempfile +import os +from chatbot.models import MediaTypeChoices +from urllib.parse import urlparse + + +@admin.action(description='Export selected stories') +def redirect_to_export_view(modeladmin, request, queryset): + selected = queryset.values_list('pk', flat=True) + query_string = urlencode({'ids': ','.join(map(str, selected))}) + return HttpResponseRedirect(f'{request.path}export_stories/?{query_string}') + + +def get_all_other_params_keys(stories): + keys = set() + for story in stories: + if isinstance(story.other_params, dict): + keys.update(story.other_params.keys()) + return sorted(keys) + + +def generate_csv_response(dataset): + response = HttpResponse(dataset.export('csv'), content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename=stories.csv' + return response + + +def generate_xls_response(dataset): + response = HttpResponse(dataset.export('xls'), content_type='application/vnd.ms-excel') + response['Content-Disposition'] = 'attachment; filename=stories.xls' + return response + + +def generate_docx_response(stories, fields_to_export): + document = Document() + headers = get_story_fields(stories, fields_to_export) + + for i, story in enumerate(stories, start=1): + document.add_heading(f'Story {i}: {story.title}', level=1) + + row_data = get_story_data(story, headers) + for field, value in zip(headers, row_data): + if field == "story_media_urls": + if not value: + continue + document.add_paragraph(f"{field.replace('_', ' ').title()}:") + urls = value.split(', ') + for url in urls: + if not url.lower().endswith('.pdf'): + try: + img_response = requests.get(url) + if img_response.status_code == 200: + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_img: + tmp_img.write(img_response.content) + tmp_img.flush() + document.add_paragraph(url) # Show URL + try: + # Try showing if it's image + document.add_picture( + tmp_img.name,width=Inches(2.5) + ) + except Exception as e: + document.add_paragraph(f"(Preview not available: {e})") + except Exception as e: + document.add_paragraph(f"Failed to load image: {url} ({e})") + else: + document.add_paragraph(url) # Non-image media, just show link + else: + document.add_paragraph(f"{field.replace('_', ' ').title()}: {value}") + + if i != len(stories): + document.add_page_break() + + doc_io = BytesIO() + document.save(doc_io) + doc_io.seek(0) + + response = HttpResponse( + doc_io.read(), + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + response['Content-Disposition'] = 'attachment; filename=stories.docx' + return response + + +def get_story_fields(stories, fields_to_export): + # base_fields = [field.name for field in Story._meta.fields if field.name != 'other_params'] + headers = fields_to_export.copy() + + extra_fields = set() + + for story in stories: + if isinstance(story.other_params, dict): + extra_fields.update(story.other_params.keys()) + + # headers = base_fields + sorted(extra_fields) + headers += sorted(extra_fields) + headers.append("story_pdfs") + headers.append("story_media_urls") + + return headers + + +def get_story_data(story, headers): + data = [] + story_dict = model_to_dict(story) + + for field in headers: + if field == 'story_pdfs': + pdf = story.story_media.filter(media_type=MediaTypeChoices.PDF).first() + value = pdf.get_public_url() if pdf else '' + elif field == 'story_media_urls': + media_urls = [ + media.get_public_url() + for media in story.story_media.all() + if media.media_type != MediaTypeChoices.PDF and media.get_public_url() + ] + if media_urls: + value = ', '.join(media_urls) + else: + value = None + + elif field in story_dict: + value = story_dict[field] + if hasattr(value, '__str__'): + value = str(value) + elif field == 'created_at' and story.created_at: + value = localtime(story.created_at).replace(tzinfo=None) + else: + value = story.other_params.get(field, '') if story.other_params else '' + data.append(value) + return data + + +def generate_zip_response(stories): + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, 'w') as zip_file: + for story in stories: + pdfs = story.story_media.filter(media_type=MediaTypeChoices.PDF) + for i, pdf in enumerate(pdfs, start=1): + print(f"Story {story.id} has {pdfs.count()} PDFs") + url = pdf.get_public_url() + if not url: + continue + try: + response = requests.get(url) + if response.status_code == 200: + # Use pdf.name, fallback to something if it's missing + base_name = get_filename_from_url(url) or pdf.name or f"story_{story.id}_media_{i}" + print("Pdf name: ", base_name) + # safe_name = slugify(base_name) + filename = f"{base_name}_{story.id}.pdf" + zip_file.writestr(filename, response.content) + else: + print(f"Failed to download from {url}, status code {response.status_code}") + except Exception as e: + print(f"Error downloading {url}: {e}") + + zip_buffer.seek(0) + response = HttpResponse(zip_buffer, content_type='application/zip') + response['Content-Disposition'] = 'attachment; filename=stories.zip' + return response + +def get_filename_from_url(url): + path = urlparse(url).path + filename = os.path.basename(path) + print("Filename from url is: ", filename) + return filename diff --git a/chatbot/routing.py b/chatbot/routing.py new file mode 100644 index 0000000..49f99ee --- /dev/null +++ b/chatbot/routing.py @@ -0,0 +1,23 @@ +from django.urls import re_path +from .consumers.Reflection_bedrock_consumer import ReflectionBedrockConsumer +from .consumers.async_chaupal_consumer import AsyncShikshalokamChaupalConsumer +from .consumers.async_consumer import AsyncSocketConsumer +from .consumers.free_flow_consumer import FreeFlowConsumer +from .consumers.guided_guest_consumer import GuidedGuestConsumer +from .consumers.mitra_bedrock_consumer import MitraBedrockConsumer +from .consumers.one_shot_bedrock_consumer import OneShotBedrockConsumer +from .consumers.oneshot_guest_consumer import OneShotGuestConsumer +from .consumers.shikshalokam_bedrock_consumer import ShikshalokamBedrockConsumer + + +websocket_urlpatterns = [ + re_path(r"ws/shikshalokam_new/$", ShikshalokamBedrockConsumer.as_asgi()), + re_path(r"ws/guided_guest/$", GuidedGuestConsumer.as_asgi()), + re_path(r"ws/reflection/$", ReflectionBedrockConsumer.as_asgi()), + re_path(r"ws/shikshalokam_one_shot/$", OneShotBedrockConsumer.as_asgi()), + re_path(r"ws/oneshot_guest/$", OneShotGuestConsumer.as_asgi()), + re_path(r"ws/mitra/$", MitraBedrockConsumer.as_asgi()), + re_path(r"ws/shikshalokam_chaupal/$", AsyncShikshalokamChaupalConsumer.as_asgi()), + re_path(r"ws/common/$", AsyncSocketConsumer.as_asgi()), + re_path(r"ws/free_flow/$", FreeFlowConsumer.as_asgi()) +] diff --git a/chatbot/scripts/__init__.py b/chatbot/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/scripts/generate_models_docs.py b/chatbot/scripts/generate_models_docs.py new file mode 100644 index 0000000..b5f2a05 --- /dev/null +++ b/chatbot/scripts/generate_models_docs.py @@ -0,0 +1,258 @@ +import os +import inspect +from django.apps import apps +from django.db.models import ( + ForeignKey, + ManyToManyField, + OneToOneField, + TextChoices, + IntegerChoices +) + +import chatbot.models.enums as enums_module + + +DEFAULT_MODELS_DOCS_PATH = "docs/backend/models.md" +DEFAULT_ENUM_DOCS_PATH = "docs/backend/enums.md" + + +# ===================================================== +# MODELS DOCUMENTATION GENERATOR (SCHEMA AWARE) +# ===================================================== + +def generate_models_docs(schema_name=None, output_path=None): + """ + Generates documentation for Django models. + + Args: + schema_name (str, optional): + App label to filter models. + Example: "chatbot", "accounts", "billing". + If None → defaults to chatbot.models filter. + + output_path (str, optional): + Output file path. + Defaults to DEFAULT_MODELS_DOCS_PATH. + """ + + output_path = output_path or DEFAULT_MODELS_DOCS_PATH + output = [] + + # --------------------------------------------------- + # HEADER (DYNAMIC) + # --------------------------------------------------- + + output.append("# Django Models\n\n") + + if schema_name: + output.append(f"`{schema_name}/models/`\n\n") + output.append( + f"This layer defines the complete database schema for the `{schema_name}` application.\n\n" + ) + else: + output.append("`chatbot/models/`\n\n") + output.append( + "This layer defines the complete database schema for the chatbot platform.\n\n" + ) + + output.append( + "It manages persistence, relationships, constraints, indexing, and " + "domain-level behavior across domain entities and system configuration.\n\n" + ) + + output.append("---\n\n") + + output.append("## Responsibilities of this Layer\n\n") + output.append("- Define core domain entities\n") + output.append("- Maintain relational integrity using ForeignKeys and constraints\n") + output.append("- Enforce validation rules and uniqueness constraints\n") + output.append("- Manage state and lifecycle tracking\n") + output.append("- Support indexing and optimized querying\n") + output.append("- Provide model-level helper methods for business logic\n") + output.append("- Use enums for consistent state definitions\n") + + output.append("\n---\n") + + # --------------------------------------------------- + # MODEL FILTERING + # --------------------------------------------------- + + all_models = apps.get_models() + + if schema_name: + models = [ + model for model in all_models + if model._meta.app_label == schema_name + and not model.__name__.startswith("Historical") + and not model._meta.abstract + ] + else: + models = [ + model for model in all_models + if model.__module__.startswith("chatbot.models") + and not model.__name__.startswith("Historical") + and not model._meta.abstract + ] + + models.sort(key=lambda m: m.__name__) + + # --------------------------------------------------- + # MODEL DOCUMENTATION + # --------------------------------------------------- + + for index, model in enumerate(models, start=1): + + meta = model._meta + model_name = model.__name__ + module_path = model.__module__.replace(".", "/") + ".py" + + output.append(f"\n## {index}. {model_name}\n\n") + output.append(f"`{module_path}`\n\n") + + # Purpose from docstring + if model.__doc__: + output.append("### Purpose\n\n") + output.append(model.__doc__.strip() + "\n\n") + + output.append("### Fields\n\n") + output.append("| Field | Type & Constraints | Description |\n") + output.append("|-------|-------------------|-------------|\n") + + for field in meta.get_fields(): + + if field.auto_created and not field.concrete: + continue + + field_name = field.name + field_type = field.__class__.__name__ + constraints = [] + + if getattr(field, "unique", False): + constraints.append("unique=True") + + if not getattr(field, "null", True): + constraints.append("required") + + if hasattr(field, "max_length") and field.max_length: + constraints.append(f"max_length={field.max_length}") + + if hasattr(field, "choices") and field.choices: + constraints.append("choices") + + if isinstance(field, ForeignKey): + constraints.append(f"ForeignKey → {field.related_model.__name__}") + + if isinstance(field, ManyToManyField): + constraints.append(f"ManyToMany → {field.related_model.__name__}") + + if isinstance(field, OneToOneField): + constraints.append(f"OneToOne → {field.related_model.__name__}") + + constraint_str = ", ".join(constraints) + description = field.help_text if field.help_text else "" + + output.append( + f"| {field_name} | {field_type} ({constraint_str}) | {description} |\n" + ) + + # Public methods + methods = [ + func for func in dir(model) + if callable(getattr(model, func)) + and not func.startswith("_") + and func not in ["save", "delete"] + ] + + if methods: + output.append("\n### Methods\n\n") + for method in methods: + output.append(f"- `{method}()`\n") + + output.append("\n---\n") + + # --------------------------------------------------- + # WRITE FILE + # --------------------------------------------------- + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, "w", encoding="utf-8") as f: + f.writelines(output) + + print(f"Models documentation generated at {output_path}") + + +# ===================================================== +# ENUMS DOCUMENTATION GENERATOR (UNCHANGED) +# ===================================================== + +def generate_enums_docs(output_path=None): + + output_path = output_path or DEFAULT_ENUM_DOCS_PATH + output = [] + + output.append("# Django Enums\n\n") + output.append("`chatbot/models/enums.py`\n\n") + output.append( + "This document defines all enumeration classes used across the platform.\n\n" + ) + output.append( + "Enums ensure consistency, validation, and type safety for status fields, " + "providers, configuration types, and workflow definitions.\n\n" + ) + + output.append("---\n") + + enum_classes = [ + (name, obj) + for name, obj in inspect.getmembers(enums_module) + if inspect.isclass(obj) + and issubclass(obj, (TextChoices, IntegerChoices)) + and obj not in (TextChoices, IntegerChoices) + ] + + enum_classes.sort(key=lambda x: x[0]) + + for index, (name, obj) in enumerate(enum_classes, start=1): + + output.append(f"\n## {index}. {name}\n\n") + + if obj.__doc__: + output.append("### Purpose\n\n") + output.append(obj.__doc__.strip() + "\n\n") + + output.append("### Values\n\n") + output.append("| Name | Value |\n") + output.append("|------|-------|\n") + + for choice in obj: + output.append(f"| {choice.name} | {choice.value} |\n") + + output.append("\n---\n") + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, "w", encoding="utf-8") as f: + f.writelines(output) + + print(f"Enums documentation generated at {output_path}") + + +# ===================================================== +# USAGE EXAMPLES +# ===================================================== + +# Default chatbot models +# generate_models_docs() + +# Specific schema +# generate_models_docs(schema_name="accounts") + +# Specific schema + custom output +# generate_models_docs( +# schema_name="observability", +# output_path="docs/apps/observability/models.md" +# ) + +# Enums (unchanged) +# generate_enums_docs() diff --git a/chatbot/scripts/guest_discussion/__init__.py b/chatbot/scripts/guest_discussion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/scripts/guest_discussion/clean_story_script.py b/chatbot/scripts/guest_discussion/clean_story_script.py new file mode 100644 index 0000000..a1dc576 --- /dev/null +++ b/chatbot/scripts/guest_discussion/clean_story_script.py @@ -0,0 +1,656 @@ +import json +import os +from chatbot.models import Story, ChatSession, CompanyChat, CompanyBot, Voice, VoiceType, ChatType, BotVernacular, \ + StoryTranslation +from chatbot.utils.audio_provider_utils import text_translate_provider +import json_repair +import logging +from django.utils.timezone import make_aware +from datetime import datetime +from retrying import retry +from chatbot.utils.llm import LLM +from chatbot.models.enums import LLMProvider +from chatbot.llm_models.llm_script import handle_bedrock_model + +from chatbot.utils.chat_utils import format_message_as_per_bedrock_format +from chatbot.utils.transliterate_utils import get_transliteration_output + +logger = logging.getLogger('django') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) + +# Constants for field categorization +TRANSLITERATE_FIELDS = ["user_name", "organization", "location", "district", "village", "block"] +NESTED_TRANSLITERATE_FIELDS = ["pri_member", "school_representative"] +TRANSLATE_FIELDS = ["title", "challenges_faced", "solutions_discussed", "remarks"] +PASSTHROUGH_FIELDS = ["participants_count", "discussion_date", "flow"] + + +def translate_field(voice_provider, message_body, target_language, source_language="en"): + """For regular translation (used for title and other text content)""" + if not message_body or message_body == '' or source_language == target_language: + return message_body + + try: + response = text_translate_provider( + voice_provider=voice_provider, + message_body=message_body, + target_language=target_language, + source_language=source_language + ) + if response.get('status') == 200: + return response.get('content') + else: + logger.warning(f"Translation failed, using original text: {message_body}") + return message_body + except Exception as e: + logger.error(f"Error translating text '{message_body}': {str(e)}") + return message_body + + +def transliterate_field(voice_provider, message_body, target_language, source_language="en"): + """For transliteration (used for location names, districts, villages, names)""" + if not message_body or message_body == '' or source_language == target_language: + return message_body + + try: + from chatbot.utils.transliterate_utils import transliterate_text + is_sentence = ' ' in message_body + response = transliterate_text( + voice_provider=voice_provider, + message_body=message_body, + target_language=target_language, + source_language=source_language, + is_sentence=is_sentence + ) + if response.get('status') == 200: + data = get_transliteration_output(response.get('content')) + return data if data else response.get('content') + else: + logger.warning(f"Transliteration failed, using original text: {message_body}") + return message_body + except Exception as e: + logger.error(f"Error transliterating text '{message_body}': {str(e)}") + return message_body + + +def process_field_value(field_name, value, target_language, source_language, translate_provider, + transliterate_provider): + """Process a field value based on its type - DRY approach""" + if not value or value == '': + return value + + # Special handling for 'others' village + if field_name in ['village', 'district', 'block'] and str(value).lower() in ['others', 'other']: + return value + + # Transliterate names and location fields + if field_name in TRANSLITERATE_FIELDS: + if transliterate_provider: + return transliterate_field( + voice_provider=transliterate_provider, + message_body=str(value), + target_language=target_language, + source_language=source_language + ) + + # Translate text content fields + elif field_name in TRANSLATE_FIELDS: + if translate_provider: + # Handle lists (like challenges_faced, solutions_discussed) + if isinstance(value, list): + return [translate_field( + voice_provider=translate_provider, + message_body=str(item), + target_language=target_language, + source_language=source_language + ) for item in value if item] + else: + return translate_field( + voice_provider=translate_provider, + message_body=str(value), + target_language=target_language, + source_language=source_language + ) + elif field_name in NESTED_TRANSLITERATE_FIELDS: + return process_nested_transliterate_field( + field_value=value, + target_language=target_language, + source_language=source_language, + transliterate_provider=transliterate_provider + ) + + # Passthrough fields (no translation/transliteration needed) + return value + + +def process_nested_transliterate_field(field_value, target_language, source_language, transliterate_provider): + """Process nested objects like pri_member and school_representative""" + if not field_value or not isinstance(field_value, dict): + return field_value + + processed_field = {} + for sub_field_name in ['name', 'designation']: + sub_field_value = field_value.get(sub_field_name, '') + if sub_field_value and sub_field_value != '': + if transliterate_provider: + processed_field[sub_field_name] = transliterate_field( + voice_provider=transliterate_provider, + message_body=str(sub_field_value), + target_language=target_language, + source_language=source_language + ) + else: + processed_field[sub_field_name] = sub_field_value + else: + processed_field[sub_field_name] = sub_field_value + + return processed_field + +def get_voice_providers(company_bot, language=None): + """Get voice providers for translation and transliteration - DRY approach""" + providers = {} + + # Try to get language-specific providers first + if language: + providers['translate'] = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.TextToText, + language=language + ).first() + + providers['transliterate'] = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.Transliterate, + language=language + ).first() + + # Fall back to default providers if language-specific not found + if not providers.get('translate'): + providers['translate'] = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.TextToText + ).first() + + if not providers.get('transliterate'): + providers['transliterate'] = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.Transliterate + ).first() + + return providers + + +def update_or_create_story_translation(story, company_bot): + """Create or update translation based on ChatSession language - processes ALL fields""" + try: + # Get the language from ChatSession + chat_session = ChatSession.objects.filter(session=story.session).first() + if not chat_session or not chat_session.language: + logger.info(f"No ChatSession or language found for Story ID {story.id}") + return + + session_language = chat_session.language + + # Skip if the session language is English (main story should be in English) + if session_language == 'en': + logger.info(f"Session language is English for Story ID {story.id}, skipping translation") + return + + # Get voice providers + providers = get_voice_providers(company_bot, session_language) + translate_provider = providers['translate'] + transliterate_provider = providers['transliterate'] + + # Prepare translated other_params - process ALL fields from story.other_params + translated_other_params = {} + + # Process ALL fields in other_params, not just updated ones + if story.other_params: + for field, value in story.other_params.items(): + translated_value = process_field_value( + field_name=field, + value=value, + target_language=session_language, + source_language="en", + translate_provider=translate_provider, + transliterate_provider=transliterate_provider + ) + translated_other_params[field] = translated_value + logger.debug(f"Translated field '{field}': {value} -> {translated_value}") + + # Translate title from English to session language + translated_title = story.title + if translate_provider and story.title: + translated_title = translate_field( + voice_provider=translate_provider, + message_body=story.title, + target_language=session_language, + source_language="en" + ) + logger.info( + f"Translated title from '{story.title}' to '{translated_title}' for language {session_language}") + + # Get or create the translation + translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=session_language, + defaults={ + 'title': translated_title, + 'content': story.content if story.content else '', + 'blurb': story.blurb if story.blurb else '', + 'tweet': story.tweet if story.tweet else '', + 'objective': story.objective if story.objective else '', + 'action_steps': story.action_steps if story.action_steps else '', + 'impact': story.impact if story.impact else '', + 'micro_improvement': story.micro_improvement if story.micro_improvement else '', + 'formatted_content': story.formatted_content if story.formatted_content else '', + 'other_params': translated_other_params # All fields translated + } + ) + + if not created: + # Update existing translation with ALL fields + translation.other_params = translated_other_params # Replace entirely with all fields + translation.title = translated_title + translation.save(update_fields=["other_params", "title"]) + logger.info(f"✅ Updated existing translation for Story ID {story.id}, language: {session_language}") + logger.info(f"Translation other_params now has {len(translated_other_params)} fields") + else: + logger.info(f"✅ Created new translation for Story ID {story.id}, language: {session_language}") + logger.info(f"Translation other_params has {len(translated_other_params)} fields") + + except Exception as e: + logger.error(f"❌ Error updating/creating translation for Story ID {story.id}: {str(e)}") + + +def correct_metadata_for_story(story): + """Main function to correct metadata and ensure English story with proper translations""" + try: + if not story.other_params: + return f"Story ID {story.id} skipped (no other_params)" + + company_bot = CompanyBot.objects.get(route='/chaupal-story-script') + + prompt = get_prompt_from_company_bot(company_bot) + if not prompt: + logger.error(f"No prompt found in company_bot context for {company_bot.id}") + return f"❌ No prompt found in company_bot context for Story ID {story.id}" + + # Get chat history + company_chats = CompanyChat.objects.filter(session=story.session).order_by('created_at') + + flow_company_bot = CompanyBot.objects.get(route='/guided_guest') + bot_vernacular = BotVernacular.objects.filter(company_bot=flow_company_bot).first() + intro_to_pass = None + if bot_vernacular: + if story.author.first_name == '' or not story.author.first_name: + intro_to_pass = bot_vernacular.alt_introductory_message + else: + intro_to_pass = bot_vernacular.introductory_message + + messages = format_message_as_per_bedrock_format(chats=company_chats, intro=intro_to_pass) + + # Get voice providers + providers = get_voice_providers(company_bot) + translate_provider = providers['translate'] + transliterate_provider = providers['transliterate'] + + formatted_prompt = [{"text": prompt}] + + tools = get_tools_from_company_bot(company_bot) + if not tools: + logger.error(f"No tools found in company_bot tool_context for {company_bot.id}") + return f"❌ No tools found in company_bot tool_context for Story ID {story.id}" + + # Get metadata from LLM + response = handle_bedrock_model( + system_prompt=formatted_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + + logger.info(f"LLM response: {response}") + result = get_clean_output(response=response) + logger.info(f"Cleaned result: {result}") + + if result and isinstance(result, str): + result = json_repair.repair_json(result, return_objects=True) + + updated = False + + # Get the session language to determine if we need to translate to English + chat_session = ChatSession.objects.filter(session=story.session).first() + session_language = chat_session.language if chat_session else 'en' + + # Title should be in English for main story + english_title = story.title + if session_language != 'en' and translate_provider: + # If session is not in English, translate title to English + english_title = translate_field( + voice_provider=translate_provider, + message_body=english_title, + target_language="en", + source_language=session_language + ) + story.title = english_title + updated = True + + # Process all metadata fields + all_fields = ["user_name", "location", "district", "village", "block", "organization", + "participants_count", "discussion_date", "challenges_faced", "solutions_discussed", + "pri_member", "school_representative", "remarks", "flow"] + + # Check if we need to translate existing TRANSLATE_FIELDS to English + if session_language != 'en' and translate_provider: + # Translate existing non-English content in TRANSLATE_FIELDS to English + for field in TRANSLATE_FIELDS: + if field in story.other_params and field != 'title': # title handled separately + current_value = story.other_params[field] + if current_value: + # Check if it's already in English or needs translation + if isinstance(current_value, list): + # For lists, check if any item contains non-ASCII characters (likely non-English) + if any(not all(ord(c) < 128 for c in str(item)) for item in current_value): + english_value = [translate_field( + voice_provider=translate_provider, + message_body=str(item), + target_language="en", + source_language=session_language + ) for item in current_value if item] + story.other_params[field] = english_value + updated = True + logger.info(f"Translated {field} to English in main story") + else: + # For strings, check if it contains non-ASCII characters + if not all(ord(c) < 128 for c in str(current_value)): + english_value = translate_field( + voice_provider=translate_provider, + message_body=str(current_value), + target_language="en", + source_language=session_language + ) + story.other_params[field] = english_value + updated = True + logger.info(f"Translated {field} to English in main story") + + # Check if we need to transliterate existing TRANSLITERATE_FIELDS to English + if session_language != 'en' and transliterate_provider: + # Handle regular transliterate fields + for field in TRANSLITERATE_FIELDS: + if field in story.other_params and story.other_params[field]: + current_value = story.other_params[field] + # Check if it contains non-ASCII characters (likely non-English) + if not all(ord(c) < 128 for c in str(current_value)): + english_value = transliterate_field( + voice_provider=transliterate_provider, + message_body=str(current_value), + target_language="en", + source_language=session_language + ) + story.other_params[field] = english_value + updated = True + logger.info(f"Transliterated {field} to English in main story") + + # Handle nested transliterate fields + for field in NESTED_TRANSLITERATE_FIELDS: + if field in story.other_params and story.other_params[field]: + current_value = story.other_params[field] + if isinstance(current_value, dict): + needs_update = False + updated_nested = {} + for sub_field in ['name', 'designation']: + sub_value = current_value.get(sub_field, '') + if sub_value and not all(ord(c) < 128 for c in str(sub_value)): + # Contains non-ASCII, needs transliteration + english_sub_value = transliterate_field( + voice_provider=transliterate_provider, + message_body=str(sub_value), + target_language="en", + source_language=session_language + ) + updated_nested[sub_field] = english_sub_value + needs_update = True + else: + updated_nested[sub_field] = sub_value + + if needs_update: + story.other_params[field] = updated_nested + updated = True + logger.info(f"Transliterated {field} to English in main story") + + # Process new fields from LLM result + for key in all_fields: + if value := result.get(key): + # Process to English (main story should be in English) + processed_value = process_field_value( + field_name=key, + value=value, + target_language="en", # Main story in English + source_language=session_language if session_language != 'en' else "en", + translate_provider=translate_provider, + transliterate_provider=transliterate_provider + ) + + story.other_params[key] = processed_value + updated = True + else: + # Handle missing fields + if key in ["organization", "pri_member", "school_representative", + "remarks", 'block'] and key not in story.other_params: + if key in ["pri_member", "school_representative"]: + story.other_params[key] = {"name": "", "designation": ""} + else: + story.other_params[key] = "" + updated = True + elif key not in story.other_params: + logger.info(f"🔸 {key} missing in Story ID {story.id}") + + if "participants_count" in story.other_params: + participants_count = story.other_params["participants_count"] + if isinstance(participants_count, str): + story.other_params["participants_count"] = { + "total": participants_count, + "women": "", + "men": "", + "children": "" + } + updated = True + logger.info(f"Converted participants_count to new format for Story ID {story.id}") + + # Ensure story language is set to English + if story.language != 'en': + story.language = 'en' + updated = True + + if updated: + fields_to_update = ["other_params", "language"] + if hasattr(story, 'title'): + fields_to_update.append("title") + + story.save(update_fields=fields_to_update) + logger.info(f"✅ Updated Story ID {story.id} to English") + logger.info(f"Story other_params now has {len(story.other_params)} fields") + + # Create/update translation with ALL fields, not just updated ones + logger.info(f"🔄 Updating/creating translation for Story ID {story.id}") + update_or_create_story_translation(story, company_bot) + + return f"✅ Updated Story ID {story.id} and its translation" + else: + # Even if no updates to main story, ensure translation has all fields + logger.info(f"🔄 Ensuring translation completeness for Story ID {story.id}") + update_or_create_story_translation(story, company_bot) + return f"✅ Ensured complete translation for Story ID {story.id}" + + except Exception as e: + logger.error(f"❌ Error in Story ID {story.id}: {str(e)}") + return f"❌ Error in Story ID {story.id}: {str(e)}" + + +def get_prompt_from_company_bot(company_bot): + """Get prompt from company bot context field""" + return company_bot.context if company_bot.context else "" + + +def get_tools_from_company_bot(company_bot): + """Get tools from company bot tool_context field""" + if not company_bot.tool_context: + return None + + try: + tools = json.loads(company_bot.tool_context) + return tools + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Error parsing tool_context for company_bot {company_bot.id}: {e}") + return None + + +def clean_all_stories(start=0, end=100): + """Clean all stories in a range""" + session_ids = list( + ChatSession.objects.filter(session_type=ChatType.shikshaChaupal) + .values_list('session', flat=True) + ) + + stories = Story.objects.filter(session__in=session_ids) \ + .exclude(other_params=None) \ + .order_by('-id')[start:end] + + print(f"Cleaning stories from {start} to {end}... Total: {stories.count()}") + logger.info(f"Cleaning stories from {start} to {end}... Total: {stories.count()}") + + results = { + 'success': 0, + 'no_changes': 0, + 'failed': 0 + } + + for story in stories: + result = correct_metadata_for_story(story) + print(result) + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['no_changes'] += 1 + else: + results['failed'] += 1 + + summary = f"Cleaning completed: {results['success']} successful, {results['no_changes']} no changes, {results['failed']} failed" + print(summary) + logger.info(summary) + return summary + + +def get_story_count(start_time=None, end_time=None): + """Get story IDs for a specific time range""" + if not start_time: + start_time = make_aware(datetime(2025, 5, 1, 0, 0)) + if not end_time: + end_time = make_aware(datetime(2025, 8, 28, 23, 59, 59)) + print(f"start_time: {start_time} and end time {end_time}") + session_ids = list( + ChatSession.objects.filter( + session_type=ChatType.shikshaChaupal, + created_at__gt=start_time, + created_at__lt=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + + if session_ids: + logger.info(f"Found {len(session_ids)} sessions") + logger.info(f"First session ID: {session_ids[0]}, Last session ID: {session_ids[-1]}") + print(f"First session id: {session_ids[0]}") + print(f"Last session id: {session_ids[-1]}") + else: + print("No sessions found.") + return [] + print(f"Total session: {len(session_ids)}") + story_ids = list( + Story.objects.filter(session__in=session_ids) + .exclude(other_params=None) + .order_by('-id') + .values_list('id', flat=True) + ) + + logger.info(f"Total stories: {len(story_ids)}") + print(f"Total stories: {len(story_ids)}") + return story_ids + + +def clean_specific_stories(story_ids): + """Clean specific stories by their IDs""" + stories = Story.objects.filter(id__in=story_ids) + + print(f"Cleaning {stories.count()} stories...") + logger.info(f"Cleaning {stories.count()} stories...") + + results = { + 'success': 0, + 'no_changes': 0, + 'failed': 0 + } + + for story in stories: + result = correct_metadata_for_story(story) + print(result) + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['no_changes'] += 1 + else: + results['failed'] += 1 + + summary = f"Cleaning completed: {results['success']} successful, {results['no_changes']} no changes, {results['failed']} failed" + print(summary) + logger.info(summary) + return summary + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response): + """Clean and format the LLM response""" + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + return response_json_content + +# Usage instructions: +# Step 1: Get story IDs for a date range +# story_ids = get_story_count(start_time,end_time) +# +# Step 2: Clean the specific stories +# clean_specific_stories(story_ids) +# +# Or clean all stories in a range: +# clean_all_stories(start=0, end=100) diff --git a/chatbot/scripts/guest_discussion/onetime_script.py b/chatbot/scripts/guest_discussion/onetime_script.py new file mode 100644 index 0000000..66f676a --- /dev/null +++ b/chatbot/scripts/guest_discussion/onetime_script.py @@ -0,0 +1,420 @@ +import json +import os +import re +from chatbot.models import Story, ChatSession, CompanyChat, CompanyBot, Voice, VoiceType, ChatType, BotVernacular, \ + StoryTranslation +import json_repair +import logging +from django.utils.timezone import make_aware +from datetime import datetime +from retrying import retry +from chatbot.utils.llm import LLM +from chatbot.models.enums import LLMProvider +from chatbot.llm_models.llm_script import handle_bedrock_model + +from chatbot.utils.chat_utils import format_message_as_per_bedrock_format + +logger = logging.getLogger('django') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + + +def safe_int(value): + """Convert to int if value contains digits, else return 0""" + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + match = re.search(r'\d+', value) + if match: + return int(match.group()) + return 0 + + +def process_participants_count(participants_count): + """Process participant count to ensure it's a dictionary with proper structure""" + if isinstance(participants_count, str): + try: + participants_count = json.loads(participants_count) + except Exception: + participants_count = { + 'total': participants_count, + 'women': '', + 'men': '', + 'children': '' + } + + # Ensure it's a dictionary with required fields + if not isinstance(participants_count, dict): + participants_count = { + 'total': str(participants_count), + 'women': '', + 'men': '', + 'children': '' + } + + # Override total participant count if possible + try: + men = safe_int(participants_count.get('men')) + women = safe_int(participants_count.get('women')) + children = safe_int(participants_count.get('children')) + + total = men + women + children + # Only override if total is greater than 0 + if total > 0: + participants_count['total'] = total + except Exception as e: + logger.info(f"Error overriding total participants count: {e}") + + return participants_count + + +def update_or_create_story_translation(story, company_bot): + """Create or update translation - only processes participant_count without translation""" + try: + # Get the language from ChatSession + chat_session = ChatSession.objects.filter(session=story.session).first() + if not chat_session or not chat_session.language: + logger.info(f"No ChatSession or language found for Story ID {story.id}") + return + + session_language = chat_session.language + + # Skip if the session language is English (main story should be in English) + if session_language == 'en': + logger.info(f"Session language is English for Story ID {story.id}, skipping translation") + return + + # Process participant count if it exists + translated_other_params = {} + + if story.other_params and 'participants_count' in story.other_params: + participant_count_value = story.other_params.get('participants_count') + if participant_count_value: + # Process participant count to ensure proper structure + processed_count = process_participants_count(participant_count_value) + translated_other_params['participants_count'] = processed_count + + # Get or create StoryTranslation + story_translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=session_language, + defaults={'other_params': translated_other_params} + ) + + # Update if already exists and there are changes + if not created: + # Only update participant_count if it exists + if 'participants_count' in translated_other_params: + if not story_translation.other_params: + story_translation.other_params = {} + story_translation.other_params['participants_count'] = translated_other_params['participants_count'] + story_translation.save() + logger.info(f"Updated participant_count for StoryTranslation ID {story_translation.id}") + else: + logger.info(f"Created new StoryTranslation ID {story_translation.id} with participant_count") + + except Exception as e: + logger.error(f"Error creating/updating translation for Story ID {story.id}: {str(e)}") + + +def get_prompt_from_company_bot(company_bot): + """Get prompt from company bot context field""" + return company_bot.context if company_bot.context else "" + + +def get_tools_from_company_bot(company_bot): + """Get tools from company bot tool_context field""" + if not company_bot.tool_context: + return None + + try: + tools = json.loads(company_bot.tool_context) + return tools + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Error parsing tool_context for company_bot {company_bot.id}: {e}") + return None + + +def correct_metadata_for_story(story): + """Get participant count from LLM and update story""" + try: + if not story.other_params: + return f"🟡 Story ID {story.id}: No other_params" + + company_bot = CompanyBot.objects.get(route='/chaupal-onetime-script') + + prompt = get_prompt_from_company_bot(company_bot) + if not prompt: + logger.error(f"No prompt found in company_bot context for {company_bot.id}") + return f"❌ No prompt found in company_bot context for Story ID {story.id}" + + # Get chat history + company_chats = CompanyChat.objects.filter(session=story.session).order_by('created_at') + + flow_company_bot = CompanyBot.objects.get(route='/guided_guest') + bot_vernacular = BotVernacular.objects.filter(company_bot=flow_company_bot).first() + intro_to_pass = None + if bot_vernacular: + if story.author.first_name == '' or not story.author.first_name: + intro_to_pass = bot_vernacular.alt_introductory_message + else: + intro_to_pass = bot_vernacular.introductory_message + + messages = format_message_as_per_bedrock_format(chats=company_chats, intro=intro_to_pass) + + formatted_prompt = [{"text": prompt}] + + tools = get_tools_from_company_bot(company_bot) + if not tools: + logger.error(f"No tools found in company_bot tool_context for {company_bot.id}") + return f"❌ No tools found in company_bot tool_context for Story ID {story.id}" + + # Get metadata from LLM + response = handle_bedrock_model( + system_prompt=formatted_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + + logger.info(f"LLM response: {response}") + result = get_clean_output(response=response) + logger.info(f"Cleaned result: {result}") + + if result and isinstance(result, str): + result = json_repair.repair_json(result, return_objects=True) + + updated = False + + # Extract participants_count from LLM response + if result and isinstance(result, dict): + participants_count_from_llm = result.get('participants_count') + + if participants_count_from_llm: + # Process the participant count from LLM + processed_count = process_participants_count(participants_count_from_llm) + + # Update story's other_params + story.other_params['participants_count'] = processed_count + updated = True + logger.info(f"Updated participants_count for Story ID {story.id}: {processed_count}") + + if updated: + story.save(update_fields=['other_params']) + logger.info(f"✅ Updated Story ID {story.id} with new participant count") + + # Update translation + update_or_create_story_translation(story, company_bot) + + return f"✅ Updated Story ID {story.id} with participant count from LLM" + else: + return f"🟡 Story ID {story.id}: No participant count found in LLM response" + + except Exception as e: + logger.error(f"❌ Error in Story ID {story.id}: {str(e)}") + return f"❌ Error in Story ID {story.id}: {str(e)}" + + +def clean_all_stories(start=0, end=100): + """Clean stories in a specific range""" + session_ids = list( + ChatSession.objects.filter(session_type=ChatType.shikshaChaupal) + .values_list('session', flat=True) + ) + + stories = Story.objects.filter(session__in=session_ids) \ + .exclude(other_params=None) \ + .order_by('-id')[start:end] + + print(f"Cleaning stories from {start} to {end}... Total: {stories.count()}") + logger.info(f"Cleaning stories from {start} to {end}... Total: {stories.count()}") + + results = { + 'success': 0, + 'no_changes': 0, + 'failed': 0 + } + + for story in stories: + result = correct_metadata_for_story(story) + print(result) + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['no_changes'] += 1 + else: + results['failed'] += 1 + + summary = f"Cleaning completed: {results['success']} successful, {results['no_changes']} no changes, {results['failed']} failed" + print(summary) + logger.info(summary) + return summary + + +def get_story_count(start_time=None, end_time=None): + """Get story IDs for a specific time range""" + if not start_time: + start_time = make_aware(datetime(2025, 9, 16, 0, 0)) + if not end_time: + end_time = make_aware(datetime(2025, 9, 30, 23, 59, 59)) + print(f"start_time: {start_time} and end time {end_time}") + + session_ids = list( + ChatSession.objects.filter( + session_type=ChatType.shikshaChaupal, + created_at__gt=start_time, + created_at__lt=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + + if session_ids: + logger.info(f"Found {len(session_ids)} sessions") + logger.info(f"First session ID: {session_ids[0]}, Last session ID: {session_ids[-1]}") + print(f"First session id: {session_ids[0]}") + print(f"Last session id: {session_ids[-1]}") + else: + print("No sessions found.") + return [] + print(f"Total session: {len(session_ids)}") + story_ids = list( + Story.objects.filter(session__in=session_ids) + .exclude(other_params=None) + .order_by('-id') + .values_list('id', flat=True) + ) + + logger.info(f"Total stories: {len(story_ids)}") + print(f"Total stories: {len(story_ids)}") + return story_ids + + +def clean_specific_stories(story_ids): + """Clean specific stories by their IDs""" + stories = Story.objects.filter(id__in=story_ids) + + print(f"Cleaning {stories.count()} stories...") + logger.info(f"Cleaning {stories.count()} stories...") + + results = { + 'success': 0, + 'no_changes': 0, + 'failed': 0 + } + + for story in stories: + result = correct_metadata_for_story(story) + print(result) + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['no_changes'] += 1 + else: + results['failed'] += 1 + + summary = f"Cleaning completed: {results['success']} successful, {results['no_changes']} no changes, {results['failed']} failed" + print(summary) + logger.info(summary) + return summary + + +def retry_if_result_none(result): + return result is None + + +def get_pricing_from_company_bot(company_bot, model_id): + """Extract pricing from company_bot.other_params""" + try: + if not company_bot.other_params: + return None + + # Parse other_params + if isinstance(company_bot.other_params, str): + other_params = json.loads(company_bot.other_params) + else: + other_params = company_bot.other_params + + # Check if pricing data exists + pricing_data = other_params.get('model_pricing') + if not pricing_data: + logger.info(f"❌ No pricing_data key found in company bot other params.") + return None + + logger.info(f"🔍 Searching for model_id: '{model_id}'") + logger.info(f"🔍 Available pricing keys: {list(pricing_data.keys())}") + + # Get pricing for current model + model_pricing = pricing_data.get(model_id) + if not model_pricing: + logger.info(f"❌ No exact match found for: '{model_id}'") + model_pricing = pricing_data.get('llama3-3-70b') + + if model_pricing and 'input_cost_per_1k' in model_pricing and 'output_cost_per_1k' in model_pricing: + return { + 'input': float(model_pricing['input_cost_per_1k']), + 'output': float(model_pricing['output_cost_per_1k']) + } + + return None + + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e: + logger.error(f"Error parsing pricing from company_bot.other_params: {e}") + return None + +def get_clean_output(response): + """ + Clean and format LLM response, recursively unwrapping 'type':'object' -> 'value' structures. + + Handles: + - Top-level response being a type-object. + - Nested fields like participants_count.total, participants_count.women, etc. + - Lists and dicts with any level of nesting. + """ + if response and isinstance(response, dict): + # Extract 'parameters' or 'input' if present (LLM function response) + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response + + # If response is a string, try to repair JSON + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + # Recursive unwrapping function + def unwrap_type_object(obj): + if isinstance(obj, dict): + # Unwrap if the dict itself is a type-object + while obj.get('type') == 'object' and 'value' in obj: + obj = obj['value'] if obj['value'] is not None else {} + # Recursively unwrap all dict values + for k, v in obj.items(): + obj[k] = unwrap_type_object(v) + return obj + elif isinstance(obj, list): + return [unwrap_type_object(item) for item in obj] + else: + return obj + + response_json_content = unwrap_type_object(response_json_content) + + return response_json_content + +# Usage instructions: +# Step 1: Get story IDs for a date range +# story_ids = get_story_count(start_time, end_time) +# +# Step 2: Clean the specific stories +# clean_specific_stories(story_ids) +# +# Or clean all stories in a range: +# clean_all_stories(start=0, end=100) \ No newline at end of file diff --git a/chatbot/scripts/guest_discussion/output/identify_non_english_story.py b/chatbot/scripts/guest_discussion/output/identify_non_english_story.py new file mode 100644 index 0000000..5c64164 --- /dev/null +++ b/chatbot/scripts/guest_discussion/output/identify_non_english_story.py @@ -0,0 +1,128 @@ +import re +from chatbot.models import Story, SessionFlowName, ChatSession + +# English letters (Hinglish allowed) +ENGLISH_LETTER_REGEX = re.compile(r'[A-Za-z]') + +# Any alphabetic letter (Latin + Devanagari) +ANY_LETTER_REGEX = re.compile(r'[A-Za-z\u00C0-\u024F\u0900-\u097F]') + +# Text fields in Story model +TEXT_FIELDS = [ + "title", + "content", + "blurb", + "tweet", + "objective", + "action_steps", + "impact", + "micro_improvement", + "location", + "district", + "state", + "block", + "formatted_content", + "summary", +] + + +def has_non_english_letters(text): + """ + Returns True ONLY if: + - text contains alphabetic letters + - AND contains NO English letters (A-Z) + """ + if not text or not isinstance(text, str): + return False + + # Ignore numbers, dates, symbols + if not ANY_LETTER_REGEX.search(text): + return False + + # Letters exist but none are English → non-English + return not ENGLISH_LETTER_REGEX.search(text) + + +def contains_non_english_in_json(obj): + """ + Recursively scan JSON (dict / list / str) for non-English text. + """ + if isinstance(obj, str): + return has_non_english_letters(obj) + + if isinstance(obj, dict): + for key, value in obj.items(): + if contains_non_english_in_json(key): + return True + if contains_non_english_in_json(value): + return True + + if isinstance(obj, list): + for item in obj: + if contains_non_english_in_json(item): + return True + + return False + + +def count_non_english_stories(): + stories = Story.objects.filter( + other_params__flow=SessionFlowName.GuestDiscussion + ) + + total_stories = stories.count() + + non_english_story_ids = [] + non_english_but_session_en_ids = [] + + for story in stories: + found_non_english = False + + # 1. Check Story fields + for field in TEXT_FIELDS: + value = getattr(story, field, None) + if has_non_english_letters(value): + found_non_english = True + break + + # 2. Check other_params JSON + if not found_non_english and story.other_params: + if contains_non_english_in_json(story.other_params): + found_non_english = True + + if found_non_english: + non_english_story_ids.append(story.id) + + # 🔹 NEW METRIC: ChatSession.language == 'en' + chat_session = ChatSession.objects.filter( + session=story.session + ).only("language").first() + + if chat_session and chat_session.language == "en": + non_english_but_session_en_ids.append(story.id) + + # ---------- STATS ---------- + non_english_count = len(non_english_story_ids) + non_english_but_en_count = len(non_english_but_session_en_ids) + + print("====================================") + print("Flow:", SessionFlowName.GuestDiscussion) + print("Total stories:", total_stories) + + print("\n--- Non-English Content ---") + print("Count:", non_english_count) + percentage = round((non_english_count / total_stories) * 100, 2) if total_stories else 0 + print("Percentage:", f"{percentage}%") + print("Story IDs:", non_english_story_ids) + + print("\n--- Non-English BUT ChatSession.language = 'en' ---") + print("Count:", non_english_but_en_count) + percentage_en = round((non_english_but_en_count / total_stories) * 100, 2) if total_stories else 0 + print("Percentage:", f"{percentage_en}%") + print("Story IDs:", non_english_but_session_en_ids) + + print("====================================") + + +# Run +count_non_english_stories() diff --git a/chatbot/scripts/guest_discussion/output/update_non_english_story.py b/chatbot/scripts/guest_discussion/output/update_non_english_story.py new file mode 100644 index 0000000..88f27ea --- /dev/null +++ b/chatbot/scripts/guest_discussion/output/update_non_english_story.py @@ -0,0 +1,451 @@ +import re +import logging +from datetime import datetime + +from chatbot.models import ( + Story, ChatSession, SessionFlowName, Voice, VoiceType +) +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.transliterate_utils import transliterate_text, get_transliteration_output + +logger = logging.getLogger("django") + +OUTPUT_FILE = "guest_discussion_fix_report.txt" + +# ---------- LANGUAGE DETECTION ---------- +ENGLISH_LETTER_REGEX = re.compile(r'[A-Za-z]') +ANY_LETTER_REGEX = re.compile(r'[A-Za-z\u00C0-\u024F\u0900-\u097F]') + + +def is_non_english_text(text): + if not text or not isinstance(text, str): + return False + # Ignore numbers / dates / symbols + if not ANY_LETTER_REGEX.search(text): + return False + return not ENGLISH_LETTER_REGEX.search(text) + + +def find_non_english_in_json(obj, path="other_params"): + found = [] + + if isinstance(obj, str): + if is_non_english_text(obj): + found.append(path) + return found + + if isinstance(obj, dict): + for k, v in obj.items(): + found.extend(find_non_english_in_json(k, f"{path}.{k} (key)")) + found.extend(find_non_english_in_json(v, f"{path}.{k}")) + + if isinstance(obj, list): + for i, item in enumerate(obj): + found.extend(find_non_english_in_json(item, f"{path}[{i}]")) + + return found + + +# ---------- MAIN SCRIPT ---------- +def fix_guest_discussion_stories(): + stories = Story.objects.filter( + other_params__flow=SessionFlowName.GuestDiscussion + ) + + total = stories.count() + fixed = [] + failed = [] + skipped = [] + + for story in stories: + offending_fields = [] + + # 🔹 CHECK STORY.LOCATION + if is_non_english_text(story.location): + offending_fields.append("story.location") + if is_non_english_text(story.title): + offending_fields.append("story.title") + + # 🔹 CHECK OTHER_PARAMS + if story.other_params: + offending_fields.extend( + find_non_english_in_json(story.other_params) + ) + + if not offending_fields: + skipped.append(story.id) + continue + + # ---------- GET SOURCE LANGUAGE ---------- + chat_session = ChatSession.objects.filter( + session=story.session + ).only("language").first() + + source_language = chat_session.language if chat_session else "en" + + # ❌ HARD FAIL RULE + if source_language == "en": + failed.append({ + "story_id": story.id, + "reason": "Non-English detected but ChatSession.language = en", + "fields": offending_fields + }) + continue + + # ---------- VOICE PROVIDERS ---------- + translation_provider = Voice.objects.filter( + type=VoiceType.TextToText, + language=source_language + ).first() + + transliteration_provider = Voice.objects.filter( + type=VoiceType.Transliterate, + language=source_language + ).first() + + updated = False + other_params = story.other_params or {} + + # ---------- FIX STORY.LOCATION ---------- + if is_non_english_text(story.location): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=story.location, + target_language="en", + source_language=source_language, + is_sentence=" " in story.location + ) + story.location = get_transliteration_output(result) + updated = True + + # ---------- TRANSLATE STORY.TITLE ---------- + if is_non_english_text(story.title): + story.title = translate_field( + voice_provider=translation_provider, + message_body=story.title, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- TRANSLATE FIELDS ---------- + for field in ["remarks"]: + val = other_params.get(field) + if val and is_non_english_text(val): + other_params[field] = translate_field( + voice_provider=translation_provider, + message_body=val, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- TRANSLATE LIST FIELDS ---------- + for list_field in ["challenges_faced", "solutions_discussed"]: + values = other_params.get(list_field) + if isinstance(values, list): + new_vals = [] + for v in values: + if v and is_non_english_text(v): + v = translate_field( + voice_provider=translation_provider, + message_body=v, + target_language="en", + source_language=source_language + ) + updated = True + new_vals.append(v) + other_params[list_field] = new_vals + + # ---------- TRANSLITERATION HELPER ---------- + def transliterate(obj, key): + nonlocal updated + val = obj.get(key) + if val and is_non_english_text(val): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=val, + target_language="en", + source_language=source_language, + is_sentence=" " in val + ) + obj[key] = get_transliteration_output(result) + updated = True + + for key in ["user_name", "organization", "location"]: + transliterate(other_params, key) + + for nested in ["pri_member", "school_representative"]: + data = other_params.get(nested) + if isinstance(data, dict): + transliterate(data, "name") + transliterate(data, "designation") + + # ---------- SAVE ---------- + if updated: + story.other_params = other_params + story.language = "en" + story.save(update_fields=["location", "title", "other_params", "language"]) + + fixed.append({ + "story_id": story.id, + "source_language": source_language, + "fields": offending_fields + }) + + # ---------- WRITE REPORT ---------- + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + f.write("GUEST DISCUSSION FIX REPORT\n") + f.write(f"Generated at: {datetime.utcnow().isoformat()} UTC\n") + f.write("=" * 70 + "\n\n") + + f.write(f"Total stories processed: {total}\n") + f.write(f"Fixed: {len(fixed)}\n") + f.write(f"Failed: {len(failed)}\n") + f.write(f"Skipped: {len(skipped)}\n\n") + + f.write("---- FAILED ----\n") + for item in failed: + f.write(f"Story ID: {item['story_id']}\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + f.write("---- FIXED ----\n") + for item in fixed: + f.write(f"Story ID: {item['story_id']} | source_language={item['source_language']}\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + print("====================================") + print("Guest Discussion Fix Completed") + print("Total:", total) + print("Fixed:", len(fixed)) + print("Failed:", len(failed)) + print("Skipped:", len(skipped)) + print("Report saved to:", OUTPUT_FILE) + print("====================================") + + +def fix_guest_discussion_stories_by_id(story_ids=None, session_id=None): + stories = Story.objects.filter( + other_params__flow=SessionFlowName.GuestDiscussion + ) + + # Apply filters based on parameters + if story_ids is not None: + # Handle single ID or list of IDs + if not isinstance(story_ids, list): + story_ids = [story_ids] + stories = stories.filter(id__in=story_ids) + print(f"🔍 Processing specific story IDs: {story_ids}") + elif session_id is not None: + stories = stories.filter(session=session_id) + print(f"🔍 Processing stories for session: {session_id}") + else: + print("🔍 Processing ALL Guest Discussion stories") + + total = stories.count() + + if total == 0: + print("❌ No stories found with the given criteria") + return + + print(f"📊 Found {total} stories to process\n") + + fixed = [] + failed = [] + skipped = [] + + # ---------- MAIN PROCESSING LOOP ---------- + for story in stories: + print(f"Processing story {story.id}...", end=" ") + + offending_fields = [] + + # 🔹 CHECK STORY.LOCATION + if is_non_english_text(story.location): + offending_fields.append("story.location") + if is_non_english_text(story.title): + offending_fields.append("story.title") + + # 🔹 CHECK OTHER_PARAMS + if story.other_params: + offending_fields.extend( + find_non_english_in_json(story.other_params) + ) + + if not offending_fields: + skipped.append(story.id) + print("✓ Skipped (no non-English)") + continue + + # ---------- GET SOURCE LANGUAGE ---------- + chat_session = ChatSession.objects.filter( + session=story.session + ).only("language").first() + + source_language = chat_session.language if chat_session else "en" + + # ❌ HARD FAIL RULE + if source_language == "en": + failed.append({ + "story_id": story.id, + "reason": "Non-English detected but ChatSession.language = en", + "fields": offending_fields + }) + print("✗ Failed (source language is English)") + continue + + # ---------- VOICE PROVIDERS ---------- + translation_provider = Voice.objects.filter( + type=VoiceType.TextToText, + language=source_language + ).first() + + transliteration_provider = Voice.objects.filter( + type=VoiceType.Transliterate, + language=source_language + ).first() + + updated = False + other_params = story.other_params or {} + + # ---------- FIX STORY.LOCATION ---------- + if is_non_english_text(story.location): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=story.location, + target_language="en", + source_language=source_language, + is_sentence=" " in story.location + ) + story.location = get_transliteration_output(result) + updated = True + + # ---------- TRANSLATE STORY.TITLE ---------- + if is_non_english_text(story.title): + story.title = translate_field( + voice_provider=translation_provider, + message_body=story.title, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- TRANSLATE FIELDS ---------- + for field in ["remarks"]: + val = other_params.get(field) + if val and is_non_english_text(val): + other_params[field] = translate_field( + voice_provider=translation_provider, + message_body=val, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- TRANSLATE LIST FIELDS ---------- + for list_field in ["challenges_faced", "solutions_discussed"]: + values = other_params.get(list_field) + if isinstance(values, list): + new_vals = [] + for v in values: + if v and is_non_english_text(v): + v = translate_field( + voice_provider=translation_provider, + message_body=v, + target_language="en", + source_language=source_language + ) + updated = True + new_vals.append(v) + other_params[list_field] = new_vals + + # ---------- TRANSLITERATION HELPER ---------- + def transliterate(obj, key): + nonlocal updated + val = obj.get(key) + if val and is_non_english_text(val): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=val, + target_language="en", + source_language=source_language, + is_sentence=" " in val + ) + obj[key] = get_transliteration_output(result) + updated = True + + for key in ["user_name", "organization", "location"]: + transliterate(other_params, key) + + for nested in ["pri_member", "school_representative"]: + data = other_params.get(nested) + if isinstance(data, dict): + transliterate(data, "name") + transliterate(data, "designation") + + # ---------- SAVE ---------- + if updated: + story.other_params = other_params + story.language = "en" + story.save(update_fields=["location", "title", "other_params", "language"]) + + fixed.append({ + "story_id": story.id, + "source_language": source_language, + "fields": offending_fields + }) + print("✓ Fixed") + else: + print("⚠️ No updates needed") + + # ---------- WRITE REPORT ---------- + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + f.write("GUEST DISCUSSION FIX REPORT\n") + f.write(f"Generated at: {datetime.utcnow().isoformat()} UTC\n") + f.write("=" * 70 + "\n\n") + + f.write(f"Total stories processed: {total}\n") + f.write(f"Fixed: {len(fixed)}\n") + f.write(f"Failed: {len(failed)}\n") + f.write(f"Skipped: {len(skipped)}\n\n") + + f.write("---- FAILED ----\n") + for item in failed: + f.write(f"Story ID: {item['story_id']}\n") + f.write(f"Reason: {item['reason']}\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + f.write("---- FIXED ----\n") + for item in fixed: + f.write(f"Story ID: {item['story_id']} | source_language={item['source_language']}\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + f.write("---- SKIPPED ----\n") + for story_id in skipped: + f.write(f"Story ID: {story_id}\n") + + print("\n" + "=" * 50) + print("📋 Guest Discussion Fix Completed") + print("=" * 50) + print(f"Total: {total}") + print(f"✅ Fixed: {len(fixed)}") + print(f"❌ Failed: {len(failed)}") + print(f"⏭️ Skipped: {len(skipped)}") + print(f"📄 Report saved to: {OUTPUT_FILE}") + print("=" * 50) + + return { + "fixed": fixed, + "failed": failed, + "skipped": skipped, + "total": total + } + diff --git a/chatbot/scripts/guest_discussion/post_processing/__init__.py b/chatbot/scripts/guest_discussion/post_processing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/scripts/guest_discussion/post_processing/challenges_script.py b/chatbot/scripts/guest_discussion/post_processing/challenges_script.py new file mode 100644 index 0000000..3dea9de --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/challenges_script.py @@ -0,0 +1,455 @@ +import logging +from typing import List, Dict, Any, Optional, Tuple +from collections import defaultdict +from tqdm import tqdm +from jinja2 import Template +from chatbot.models import CompanyBot +import json +import os +import json_repair +from retrying import retry +from concurrent.futures import ThreadPoolExecutor, as_completed +from chatbot.utils.llm import LLM +from chatbot.models.enums import LLMProvider +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.constants.post_processing_constants import CHALLENGE_CATEGORIES + +logger = logging.getLogger('django') + + +# -------------- CONFIG ------------------ +INPUT_FILE = 'chatbot/scripts/guest_discussion/post_processing/chaupal_four_challenge.json' +OUTPUT_FILE = 'chatbot/scripts/challenges/llm_unique_challenges_output.json' +SECOND_OUTPUT_FILE = 'chatbot/scripts/challenges/flat_challenges_output.json' +DEFAULT_BATCH_SIZE = 5 +DEFAULT_MAX_WORKERS = 2 +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', '3')) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + +# -------------- CORE FUNCTIONS ------------------ + +def chunk_data(data: List[Dict[str, Any]], batch_size: int) -> List[List[Dict[str, Any]]]: + """Split list of challenge dicts into batches.""" + return [data[i:i + batch_size] for i in range(0, len(data), batch_size)] + + +def build_user_message(batch: List[Dict[str, Any]], company_bot) -> List[Dict[str, Any]]: + """Build the user message for the LLM using tag_context from CompanyBot. + + Each item in batch is a dict with keys: challenge_text, challenge_count, category. + """ + challenges_text = "\n".join( + [f"- [count: {item['challenge_count']}] {item['challenge_text']}" for item in batch] + ) + + categories_list = ", ".join(CHALLENGE_CATEGORIES) + + # Render the tag_context Jinja2 template with variables + context_data = { + "challenges_text": challenges_text, + "categories_list": categories_list, + } + + template = Template(company_bot.tag_context) + prompt_text = template.render(context_data) + + logger.info(f"[challenges_script] Rendered prompt for batch (first 500 chars): {prompt_text[:500]}") + + return [ + { + 'role': 'user', + 'content': [{ + 'text': prompt_text + }] + } + ] + +def call_llm(batch: List[Dict[str, Any]], index: int) -> Dict[str, Any]: + """Call LLM for a batch of challenge dicts and return parsed result. + + Returns: + { + "challenges": List[dict] or None, + "categories": List[dict] or None + } + """ + try: + company_bot = CompanyBot.objects.filter(route='/challenges_script').first() + if not company_bot: + logger.error("[challenges_script] CompanyBot with route '/challenges_script' not found.") + return {"challenges": None, "categories": None} + + if not company_bot.tag_context: + logger.error("[challenges_script] tag_context is empty for CompanyBot route='/challenges_script'. Please set the prompt template in admin.") + return {"challenges": None, "categories": None} + + messages = build_user_message(batch, company_bot) + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + formatted_prompt = [{ + 'text': company_bot.context + }] + + output = handle_bedrock_model( + system_prompt=formatted_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool + ) + if output: + parsed = get_clean_output(response=output) + return parsed if parsed else {"challenges": None, "categories": None} + + return {"challenges": None, "categories": None} + except Exception as e: + print(f"Error in call_llm for batch {index}: {str(e)}") + return {"challenges": None, "categories": None} + + +def process_all_batches( + data: List[Dict[str, Any]], + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + save_to_file: bool = False, + output_file: str = OUTPUT_FILE +) -> Dict[str, Any]: + """Process all batches and return results dictionary. """ + chunks = chunk_data(data, batch_size) + results = {} + + print(f"🚀 Starting processing of {len(chunks)} batches with {max_workers} workers...") + + def run_one_batch(idx_batch): + idx, batch = idx_batch + print(f"[Worker] Running batch {idx}") + result = call_llm(batch, idx) + return idx, result + + # Parallel execution + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(run_one_batch, (i, chunk)) for i, chunk in enumerate(chunks)] + + for future in tqdm(as_completed(futures), total=len(futures), desc="Batches Completed"): + idx, result = future.result() + batch_key = f"challenge_{idx}" + results[batch_key] = { + "challenges": result.get("challenges"), + "categories": result.get("categories") + } + + if save_to_file: + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + print(f"✅ Saved {len(chunks)} batches to {output_file}") + + return results + + +# -------------- ENTRY POINT ------------------ +def run_unique_challenge_processing( + start: int = 0, + end: int = None, + input_file: str = None, + input_data: List[Dict[str, Any]] = None, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + save_to_file: bool = False, + output_file: str = OUTPUT_FILE, + second_output_file: str = SECOND_OUTPUT_FILE +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Run unique challenge processing. + """ + # Get challenges from input_data or input_file + if input_data is not None: + challenges = input_data + elif input_file: + with open(input_file, "r") as f: + raw = json.load(f) + challenges = _ensure_challenge_dicts(raw) + else: + input_file = INPUT_FILE + with open(input_file, "r") as f: + raw = json.load(f) + challenges = _ensure_challenge_dicts(raw) + + total = len(challenges) + end = end if end is not None else total + selected_challenges = challenges[start:end] + + print(f"🚀 Loaded {len(selected_challenges)} challenges from index {start} to {end} (Total available: {total})") + + # Process batches + batch_results = process_all_batches( + selected_challenges, + batch_size=batch_size, + max_workers=max_workers, + save_to_file=save_to_file, + output_file=output_file + ) + + # Combine batch results (pass expected_total for cross-batch normalization) + expected_total = sum(c.get('challenge_count', 1) for c in selected_challenges) + combined = combine_batch_results( + batch_results=batch_results, + expected_total=expected_total, + save_to_file=save_to_file, + save_file_path=second_output_file + ) + + return batch_results, combined + + +def _ensure_challenge_dicts(data: list) -> List[Dict[str, Any]]: + """Convert raw data (strings or dicts) into the standard challenge dict format.""" + result = [] + if not isinstance(data, list): + return result + for item in data: + if isinstance(item, str) and item.strip(): + result.append({ + 'challenge_text': item.strip(), + 'challenge_count': 1, + 'category': '' + }) + elif isinstance(item, dict): + # Support both old {'challenge': '...'} and new {'challenge_text': '...'} formats + text = item.get('challenge_text') or item.get('challenge') or '' + if isinstance(text, str) and text.strip(): + result.append({ + 'challenge_text': text.strip(), + 'challenge_count': item.get('challenge_count', 1), + 'category': item.get('category', '') + }) + return result + + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response) -> Optional[Dict[str, Any]]: + """Parse LLM response and extract challenges + categories. + """ + try: + if isinstance(response, str): + try: + response = json_repair.repair_json(response, return_objects=True) + except Exception: + return None + + # Unwrap tool-call wrappers + if isinstance(response, dict): + if 'type' in response and 'value' in response: + return get_clean_output(response.get('value')) + + # Unwrap common wrapper keys + if 'parameters' in response: + return get_clean_output(response.get('parameters')) + if 'input' in response and 'unique_challenges' not in response: + return get_clean_output(response.get('input')) + + # --- Main parsing: expect {unique_challenges: [...], categories: [...]} --- + # LLM sometimes uses 'challenges' instead of 'unique_challenges' + raw_challenges = response.get('unique_challenges') or response.get('challenges') + raw_categories = response.get('categories') + + if raw_challenges is not None: + challenges = _parse_challenge_items(raw_challenges) + categories = _parse_category_items(raw_categories) + if challenges: + return {"challenges": challenges, "categories": categories} + + # Handle case where LLM returns a plain list (backward compat) + if isinstance(response, list): + challenges = _parse_challenge_items(response) + if challenges: + return {"challenges": challenges, "categories": []} + + return None + except Exception as e: + print(f"Error in get_clean_output: {str(e)}") + return None + + +def _parse_challenge_items(data) -> List[Dict[str, Any]]: + """Parse a list of challenge items from LLM output. + Handles both actual lists and stringified JSON arrays from tool-use responses. + """ + # Handle stringified JSON (LLM sometimes returns arrays as strings in tool-use) + if isinstance(data, str): + try: + data = json_repair.repair_json(data, return_objects=True) + except Exception: + return [] + + if not isinstance(data, list): + return [] + + cleaned = [] + for item in data: + if isinstance(item, dict): + text = item.get('challenge_text', '') + if isinstance(text, str) and text.strip(): + cleaned.append({ + 'challenge_text': text.strip(), + 'challenge_count': item.get('challenge_count', 1), + 'category': item.get('category', '') + }) + elif isinstance(item, str) and item.strip(): + # Backward compat: plain string → wrap as dict with count 1 + cleaned.append({ + 'challenge_text': item.strip(), + 'challenge_count': 1, + 'category': '' + }) + return cleaned + + +def _parse_category_items(data) -> List[Dict[str, Any]]: + """Parse category count items from LLM output. + Handles: list of {category_name, category_count}, dict of {name: count}, + and stringified JSON versions of both. + """ + # Handle stringified JSON + if isinstance(data, str): + try: + data = json_repair.repair_json(data, return_objects=True) + except Exception: + return [] + + # Handle dict format: {"Challenges": 83, "Positive Observations": 4, ...} + if isinstance(data, dict): + cleaned = [] + for name, count in data.items(): + if name and isinstance(count, (int, float)): + cleaned.append({ + 'category_name': str(name), + 'category_count': int(count) + }) + return cleaned + + if not isinstance(data, list): + return [] + + cleaned = [] + for item in data: + if isinstance(item, dict): + name = item.get('category_name', '') + count = item.get('category_count', 0) + if name: + cleaned.append({ + 'category_name': str(name), + 'category_count': int(count) if count else 0 + }) + return cleaned + + + +def _normalize_challenge_counts(challenges: List[Dict[str, Any]], expected_total: int) -> List[Dict[str, Any]]: + """Normalize challenge counts so they sum to expected_total (scales both up and down). + """ + if not challenges or expected_total <= 0: + return challenges + + actual_total = sum(c.get('challenge_count', 1) for c in challenges) + if actual_total == expected_total or actual_total == 0: + return challenges + + ratio = expected_total / actual_total + for c in challenges: + c['challenge_count'] = max(1, round(c['challenge_count'] * ratio)) + + # Fix any rounding drift (±1 or ±2) by adjusting the largest items + diff = expected_total - sum(c['challenge_count'] for c in challenges) + challenges.sort(key=lambda c: c['challenge_count'], reverse=True) + for c in challenges: + if diff == 0: + break + adj = 1 if diff > 0 else -1 + if c['challenge_count'] + adj >= 1: + c['challenge_count'] += adj + diff -= adj + + return challenges + +def combine_batch_results( + batch_results: Dict[str, Any] = None, + expected_total: int = None, + output_file_path: str = OUTPUT_FILE, + save_to_file: bool = False, + save_file_path: str = SECOND_OUTPUT_FILE +) -> Dict[str, Any]: + """ + Combine batch-wise LLM output into a single result. + If expected_total is provided, normalizes combined counts to match it. + """ + all_challenges = [] + category_counts = defaultdict(int) + + # Use provided batch_results or load from file + if batch_results is not None: + challenges_dict = batch_results + else: + if not os.path.exists(output_file_path): + print(f"⚠️ Warning: Output file {output_file_path} not found.") + return {"challenges": [], "category_counts": {}} + + try: + with open(output_file_path, "r") as f: + challenges_dict = json.load(f) + except Exception as e: + print(f"❌ Error reading {output_file_path}: {e}") + return {"challenges": [], "category_counts": {}} + + # Combine challenges and aggregate category counts from all batches + for _, batch_data in challenges_dict.items(): + if not isinstance(batch_data, dict): + continue + + # Collect challenges + challenges_list = batch_data.get("challenges") + if isinstance(challenges_list, list): + for challenge in challenges_list: + if isinstance(challenge, dict) and challenge.get('challenge_text', '').strip(): + all_challenges.append({ + 'challenge_text': challenge['challenge_text'].strip(), + 'challenge_count': challenge.get('challenge_count', 1), + 'category': challenge.get('category', '') + }) + + # Normalize challenge counts to match expected_total (once, at combine level) + if expected_total is not None: + all_challenges = _normalize_challenge_counts(all_challenges, expected_total) + + # Compute category counts from the normalized challenge items (so both are consistent) + for challenge in all_challenges: + cat = challenge.get('category', '') + if cat: + category_counts[cat] += challenge.get('challenge_count', 1) + + # Save if requested + if save_to_file: + try: + save_data = { + "challenges": all_challenges, + "category_counts": dict(category_counts) + } + with open(save_file_path, "w") as f: + json.dump(save_data, f, indent=2) + print(f"✅ Combined results saved to {save_file_path}") + except Exception as e: + print(f"❌ Error saving file: {e}") + + print(f"Combined challenges count: {len(all_challenges)}") + return { + "challenges": all_challenges, + "category_counts": dict(category_counts) + } + + +if __name__ == "__main__": + run_unique_challenge_processing() diff --git a/chatbot/scripts/guest_discussion/post_processing/district_challenges_script.py b/chatbot/scripts/guest_discussion/post_processing/district_challenges_script.py new file mode 100644 index 0000000..fe20955 --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/district_challenges_script.py @@ -0,0 +1,223 @@ +from typing import List, Dict, Any +from tqdm import tqdm +from chatbot.models import CompanyBot +import json +import os +import json_repair +from retrying import retry +from concurrent.futures import ThreadPoolExecutor, as_completed +from chatbot.utils.llm import LLM +from chatbot.models.enums import LLMProvider +from chatbot.llm_models.llm_script import handle_bedrock_model + + +# -------------- CONFIG ------------------ +INPUT_FILE = 'chatbot/scripts/common_challenges/district_challenge.json' +OUTPUT_FILE = 'chatbot/scripts/common_challenges/llm_district_challenges_output.json' +SECOND_OUTPUT_FILE = 'chatbot/scripts/common_challenges/flat_district_challenges_output.json' +BATCH_SIZE = 3 +MAX_WORKERS = 2 +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER')) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + + +def chunk_data(data: List[str], batch_size: int) -> List[List[str]]: + return [data[i:i + batch_size] for i in range(0, len(data), batch_size)] + + +def build_user_message(batch: List[str]) -> List[Dict[str, Any]]: + challenges_text = "\n".join([f"- {challenge}" for challenge in batch]) + return [ + { + 'role': 'user', + 'content': [{ + 'text': f"""Given the list of challenges below, Identify Top 3 common challenges observed:\n\n{challenges_text}\n\n""" + }] + } + ] + + +def call_llm(batch: List[str], index: int) -> Dict[str, Any]: + messages = build_user_message(batch) + company_bot = CompanyBot.objects.filter(route='/district_challenges_script').first() + if not company_bot: + return {f"batch_{index}_error": "No Bot Found"} + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + formatted_prompt = [{ + 'text': company_bot.context + }] + + output = handle_bedrock_model( + system_prompt=formatted_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool + ) + + if output: + output=get_clean_output(response=output) + + key = f"challenge" + return {key: output} + + +def extract_district_challenges(data: List[Dict[str, str]], district_name: str) -> List[str]: + district_challenges = [] + for entry in data: + challenge = entry.get(district_name) + print("working onL ", challenge) + if challenge: + print("type working onL ", type(challenge)) + if isinstance(challenge, list): + for item in challenge: + if item and isinstance(item, str) and item.strip(): + district_challenges.append(item.strip()) + elif isinstance(challenge, str) and challenge.strip(): + district_challenges.append(challenge.strip()) + return district_challenges + + +def process_one_district_batches(district_name: str, challenges: List[str]): + chunks = chunk_data(challenges, BATCH_SIZE) + district_output = {} + + print(f"🚀 Starting {district_name} with {len(challenges)} challenges in {len(chunks)} batches") + + def run_one_batch(idx_batch): + idx, batch = idx_batch + print(f"[{district_name}] Running batch {idx}") + result = call_llm(batch, idx) + return idx, result + + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + futures = [executor.submit(run_one_batch, (i, chunk)) for i, chunk in enumerate(chunks)] + + for future in tqdm(as_completed(futures), total=len(futures), desc=f"{district_name} Progress"): + idx, result = future.result() + key = f"top_challenge" + batch_key = f"{key}_{idx}" + district_output[batch_key] = result.get("challenge") + + # Load full existing file, update only district part + full_output = {} + if os.path.exists(OUTPUT_FILE): + with open(OUTPUT_FILE, "r") as f: + try: + full_output = json.load(f) + except json.JSONDecodeError: + print("⚠️ Warning: Output file corrupted. Starting fresh.") + + full_output[district_name] = district_output + + with open(OUTPUT_FILE, "w") as f: + json.dump(full_output, f, indent=2) + + print(f"✅ Finished processing for {district_name}. Saved to {OUTPUT_FILE}") + + +# -------------- ENTRY POINT ------------------ +def run_common_challenge_processing(district_name: str, input_file: str = INPUT_FILE): + with open(input_file, "r") as f: + all_data = json.load(f) + if all_data and isinstance(all_data, dict): + all_data = [all_data] + + challenges = extract_district_challenges(all_data, district_name) + + if not challenges: + print(f"⚠️ No challenges found for district: {district_name}") + return + + print(f"✅ Found {len(challenges)} challenges for {district_name}") + process_one_district_batches(district_name, challenges) + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response): + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response.get('common_challenges') + reason_content = response.get('reason_for_commonality') + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + print("response_json_content: ", response_json_content) + print("reason_content: ", reason_content) + + return response_json_content + + +def convert_district_challenges_to_flat_list(output_file_path=OUTPUT_FILE, save_file_path=SECOND_OUTPUT_FILE): + district_challenges_output = {} + + # Check if output file exists + if not os.path.exists(output_file_path): + print(f"⚠️ Warning: Output file {output_file_path} not found.") + return district_challenges_output + + # Read data from output file + try: + with open(output_file_path, "r") as f: + district_challenges_dict = json.load(f) + except json.JSONDecodeError: + print(f"⚠️ Warning: Output file {output_file_path} is corrupted or empty.") + return district_challenges_output + except Exception as e: + print(f"❌ Error reading file {output_file_path}: {e}") + return district_challenges_output + + # Iterate through all districts + for district_name, district_batches in district_challenges_dict.items(): + # Initialize district list if not exists + if district_name not in district_challenges_output: + district_challenges_output[district_name] = [] + + if isinstance(district_batches, dict): + # Iterate through all batches for this district + for batch_key, challenges_list in district_batches.items(): + # Check if the value is a list and not None + if isinstance(challenges_list, list): + # Add each challenge string to the district's list + for challenge_text in challenges_list: + if challenge_text and isinstance(challenge_text, str): + district_challenges_output[district_name].append(challenge_text) + elif challenges_list and isinstance(challenges_list, str): + # Handle case where challenges_list is a single string + district_challenges_output[district_name].append(challenges_list) + + print("district_challenges_output: ", district_challenges_output) + print("Total districts: ", len(district_challenges_output)) + + # Save the district challenges to a new file + try: + with open(save_file_path, "w") as f: + json.dump(district_challenges_output, f, indent=2) + print(f"✅ Converted district_challenges saved to {save_file_path}") + except Exception as e: + print(f"❌ Error saving to file {save_file_path}: {e}") + + return district_challenges_output diff --git a/chatbot/scripts/guest_discussion/post_processing/guest_challenges_faced_mitigation_script.py b/chatbot/scripts/guest_discussion/post_processing/guest_challenges_faced_mitigation_script.py new file mode 100644 index 0000000..390a4d8 --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/guest_challenges_faced_mitigation_script.py @@ -0,0 +1,511 @@ +import argparse +import json +import logging +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import json_repair +except ImportError: + class _JsonRepairFallback: + @staticmethod + def repair_json(value, return_objects=True): + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return value + return value + + json_repair = _JsonRepairFallback() + +try: + CURRENT_FILE = Path(__file__).resolve() +except NameError: + CURRENT_FILE = Path.cwd() +PROJECT_ROOT = None +for parent in CURRENT_FILE.parents: + if (parent / "manage.py").exists(): + PROJECT_ROOT = parent + break + +if PROJECT_ROOT is not None: + project_root_str = str(PROJECT_ROOT) + if project_root_str not in sys.path: + sys.path.insert(0, project_root_str) + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shikshalokam_mohini.settings") + +import django + +django.setup() + +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, CompanyChat, LLMProvider, Story, StoryTranslation +from chatbot.utils.chat_utils import get_guided_chat + +logger = logging.getLogger("django") + +DEFAULT_STAGE_NAME = "CHALLENGES" +DEFAULT_BOT_ROUTE = "/fix-challenges-bot" +DEFAULT_MAX_WORKERS = 4 + + +def _load_json_file(file_path: str) -> Any: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def load_session_ids(input_json_file: str) -> List[str]: + """Load session IDs from JSON.""" + data = _load_json_file(input_json_file) + + session_ids: List[str] = [] + if isinstance(data, list): + session_ids = [str(item).strip() for item in data if str(item).strip()] + elif isinstance(data, dict): + raw = data.get("session_ids", []) + if isinstance(raw, list): + session_ids = [str(item).strip() for item in raw if str(item).strip()] + + # Preserve order and de-duplicate + seen = set() + unique_ids = [] + for session_id in session_ids: + if session_id not in seen: + seen.add(session_id) + unique_ids.append(session_id) + + return unique_ids + + +def _try_parse_json(response: Any) -> Any: + if isinstance(response, str): + try: + return json_repair.repair_json(response, return_objects=True) + except Exception: + return response + return response + + +def unwrap_llm_response(response: Any) -> Any: + """Unwrap tool-call wrappers and nested {type: object, value: ...} structures.""" + response = _try_parse_json(response) + + if isinstance(response, dict): + extracted = response.get("parameters") or response.get("input") + if isinstance(extracted, dict): + response = extracted + + def _unwrap(obj: Any) -> Any: + if isinstance(obj, dict): + while obj.get("type") == "object" and "value" in obj: + obj = obj.get("value") or {} + return {k: _unwrap(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_unwrap(item) for item in obj] + return obj + + return _unwrap(response) + + +def normalize_challenges_faced(value: Any) -> Optional[List[str]]: + """Coerce challenges_faced to a clean list of strings.""" + if isinstance(value, str): + try: + parsed = json_repair.repair_json(value, return_objects=True) + except Exception: + parsed = value + if isinstance(parsed, list): + value = parsed + else: + return None + + if not isinstance(value, list): + return None + + result = [] + for item in value: + if isinstance(item, str): + item = item.replace('\\"', '"').replace("\\'", "'").strip() + result.append(item) + + return result or None + + +def extract_challenges_faced_from_response(response: Any) -> Optional[Any]: + """Extract challenges_faced from bot response exactly as returned by mitigation bot.""" + cleaned = unwrap_llm_response(response) + + if isinstance(cleaned, dict): + challenges_faced = cleaned.get("challenges_faced") + if challenges_faced is not None: + return normalize_challenges_faced(challenges_faced) + + return None + + +def get_mitigation_bot(route: str = DEFAULT_BOT_ROUTE) -> CompanyBot: + bot = CompanyBot.objects.filter(route=route).first() + if not bot: + raise ValueError(f"CompanyBot with route '{route}' not found") + return bot + + +def resolve_bedrock_tools(tool_context: Any) -> Optional[Dict[str, Any]]: + """Resolve the inner Bedrock toolConfig from a bot tool_context payload.""" + if not tool_context: + return None + + if isinstance(tool_context, str): + try: + tool_context = json_repair.repair_json(tool_context, return_objects=True) + except Exception: + return None + + if isinstance(tool_context, dict): + if tool_context.get("toolConfig"): + return tool_context + + for key in ("content_tool", "story_tool", "tool"): + candidate = tool_context.get(key) + if isinstance(candidate, dict) and candidate.get("toolConfig"): + return candidate + + for value in tool_context.values(): + if isinstance(value, dict) and value.get("toolConfig"): + return value + + if isinstance(tool_context, list) and tool_context: + for item in tool_context: + if isinstance(item, dict) and item.get("toolConfig"): + return item + + return None + + +def get_challenges_stage_chats( + session_id: str, + stage_name: str = DEFAULT_STAGE_NAME, +) -> List[CompanyChat]: + """Return all chats for the requested stage in chronological order.""" + chats = list(CompanyChat.objects.filter(session=session_id).order_by("created_at")) + if not chats: + return [] + + return [c for c in chats if c.stage == stage_name] + + +def call_temporary_mitigation_bot( + mitigation_bot: CompanyBot, + relevant_chats: List[CompanyChat], +) -> Any: + """Call mitigation bot with the provided chat slice.""" + messages = get_guided_chat(company_bot=mitigation_bot, company_chats=relevant_chats) + + if mitigation_bot.provider == LLMProvider.BEDROCK_CONVERSE: + tools = resolve_bedrock_tools(mitigation_bot.tool_context) + + system_prompt = [{"text": mitigation_bot.context}] if mitigation_bot.context else None + + return handle_bedrock_model( + system_prompt=system_prompt, + messages=messages, + model_name=mitigation_bot.llm_model, + temperature=mitigation_bot.bot_temperature, + max_token=mitigation_bot.max_token, + company_bot=mitigation_bot, + tools=tools, + ) + + if mitigation_bot.provider == LLMProvider.OPENAI: + system_prompt = [ + { + "role": "system", + "content": mitigation_bot.context or "", + } + ] + return handle_openai_model( + system_prompt=system_prompt, + messages=messages, + model_name=mitigation_bot.llm_model, + temperature=mitigation_bot.bot_temperature, + max_token=mitigation_bot.max_token, + is_json_response=True, + ) + + raise ValueError(f"Unsupported provider for mitigation bot: {mitigation_bot.provider}") + + +def update_story_and_translations( + story: Story, + challenges_faced: Any, + dry_run: bool = True, +) -> Tuple[bool, bool]: + """Update story.other_params and all translations. Returns (story_updated, translations_updated).""" + story_updated = False + translations_updated = False + + other_params = story.other_params if isinstance(story.other_params, dict) else {} + current = other_params.get("challenges_faced") + + if current != challenges_faced: + other_params["challenges_faced"] = challenges_faced + story.other_params = other_params + story_updated = True + if not dry_run: + story.save(update_fields=["other_params"]) + + translations = StoryTranslation.objects.filter(story=story) + for tr in translations: + tr_other_params = tr.other_params if isinstance(tr.other_params, dict) else {} + if tr_other_params.get("challenges_faced") != challenges_faced: + tr_other_params["challenges_faced"] = challenges_faced + tr.other_params = tr_other_params + translations_updated = True + if not dry_run: + tr.save(update_fields=["other_params"]) + + return story_updated, translations_updated + + +def process_single_session( + session_id: str, + mitigation_bot: CompanyBot, + stage_name: str = DEFAULT_STAGE_NAME, + dry_run: bool = True, +) -> Dict[str, Any]: + """Process one session and update challenges_faced for guest-discussion story only.""" + result = { + "session": session_id, + "status": "skipped", + "reason": "", + "story_id": None, + "challenges_faced": None, + "story_updated": False, + "translations_updated": False, + } + + try: + story = Story.objects.filter(session=session_id).first() + if not story: + result["reason"] = "story_not_found" + return result + + result["story_id"] = story.id + + other_params = story.other_params if isinstance(story.other_params, dict) else {} + if other_params.get("flow") != "guest-discussion": + result["reason"] = "not_guest_discussion" + return result + + relevant_chats = get_challenges_stage_chats( + session_id=session_id, + stage_name=stage_name, + ) + if not relevant_chats: + result["reason"] = "no_relevant_chats" + return result + + llm_response = call_temporary_mitigation_bot( + mitigation_bot=mitigation_bot, + relevant_chats=relevant_chats, + ) + + new_challenges_faced = extract_challenges_faced_from_response(llm_response) + if not new_challenges_faced: + result["reason"] = "challenges_faced_not_found_in_bot_response" + return result + + story_updated, translations_updated = update_story_and_translations( + story=story, + challenges_faced=new_challenges_faced, + dry_run=dry_run, + ) + + result["challenges_faced"] = new_challenges_faced + result["story_updated"] = story_updated + result["translations_updated"] = translations_updated + result["status"] = "updated" if (story_updated or translations_updated) else "no_change" + return result + + except Exception as e: + logger.exception("Error while processing session %s", session_id) + result["status"] = "failed" + result["reason"] = str(e) + return result + + +def process_sessions_from_json( + input_json_file: str, + dry_run: bool = True, + bot_route: str = DEFAULT_BOT_ROUTE, + stage_name: str = DEFAULT_STAGE_NAME, + max_workers: int = DEFAULT_MAX_WORKERS, +) -> Dict[str, Any]: + """Main entrypoint.""" + if not os.path.exists(input_json_file): + raise FileNotFoundError(f"Input JSON file not found: {input_json_file}") + + session_ids = load_session_ids(input_json_file) + if not session_ids: + return { + "dry_run": dry_run, + "total_sessions": 0, + "updated": 0, + "no_change": 0, + "skipped": 0, + "failed": 0, + "results": [], + } + + mitigation_bot = get_mitigation_bot(route=bot_route) + max_workers = max(1, int(max_workers or 1)) + + results: List[Dict[str, Any]] = [] + summary = { + "dry_run": dry_run, + "total_sessions": len(session_ids), + "updated": 0, + "no_change": 0, + "skipped": 0, + "failed": 0, + "results": results, + } + + logger.info( + "Starting challenges_faced mitigation for %s sessions. dry_run=%s", + len(session_ids), + dry_run, + ) + + def run_one(idx_and_session: Tuple[int, str]) -> Tuple[int, Dict[str, Any]]: + idx, session_id = idx_and_session + row = process_single_session( + session_id=session_id, + mitigation_bot=mitigation_bot, + stage_name=stage_name, + dry_run=dry_run, + ) + return idx, row + + indexed_results: List[Tuple[int, Dict[str, Any]]] = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(run_one, (idx, session_id)) + for idx, session_id in enumerate(session_ids) + ] + + for future in as_completed(futures): + idx, row = future.result() + indexed_results.append((idx, row)) + + if row["status"] == "updated": + summary["updated"] += 1 + elif row["status"] == "no_change": + summary["no_change"] += 1 + elif row["status"] == "failed": + summary["failed"] += 1 + else: + summary["skipped"] += 1 + + for _, row in sorted(indexed_results, key=lambda item: item[0]): + results.append(row) + + logger.info( + "Mitigation completed. total=%s updated=%s no_change=%s skipped=%s failed=%s dry_run=%s", + summary["total_sessions"], + summary["updated"], + summary["no_change"], + summary["skipped"], + summary["failed"], + summary["dry_run"], + ) + + return summary + + +def _setup_django() -> None: + """Bootstrap Django so this script can be executed directly from terminal.""" + try: + current = Path(__file__).resolve() + except NameError: + current = Path.cwd() + project_root = None + + for parent in current.parents: + if (parent / "manage.py").exists(): + project_root = parent + break + + if project_root is None: + raise RuntimeError("Could not locate project root containing manage.py") + + project_root_str = str(project_root) + if project_root_str not in sys.path: + sys.path.insert(0, project_root_str) + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shikshalokam_mohini.settings") + + import django + + django.setup() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Mitigate challenges_faced for guest-discussion stories using /fix-challenges-bot." + ) + parser.add_argument( + "--input-json", + required=True, + help="Path to input JSON containing session IDs.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without saving to DB.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Apply updates to DB (overrides --dry-run).", + ) + parser.add_argument( + "--bot-route", + default=DEFAULT_BOT_ROUTE, + help="Mitigation bot route. Default: /fix-challenges-bot", + ) + parser.add_argument( + "--stage-name", + default=DEFAULT_STAGE_NAME, + help="Stage name for challenges context. Default: CHALLENGES", + ) + parser.add_argument( + "--max-workers", + type=int, + default=DEFAULT_MAX_WORKERS, + help="ThreadPoolExecutor worker count. Default: 4", + ) + return parser.parse_args() + + +if __name__ == "__main__": + _setup_django() + args = _parse_args() + dry_run = False if args.apply else True + if args.dry_run: + dry_run = True + + summary = process_sessions_from_json( + input_json_file=args.input_json, + dry_run=dry_run, + bot_route=args.bot_route, + stage_name=args.stage_name, + max_workers=args.max_workers, + ) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) diff --git a/chatbot/scripts/guest_discussion/post_processing/guest_solutions_discussed_mitigation_script.py b/chatbot/scripts/guest_discussion/post_processing/guest_solutions_discussed_mitigation_script.py new file mode 100644 index 0000000..91325ad --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/guest_solutions_discussed_mitigation_script.py @@ -0,0 +1,511 @@ +import argparse +import json +import logging +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import json_repair +except ImportError: + class _JsonRepairFallback: + @staticmethod + def repair_json(value, return_objects=True): + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return value + return value + + json_repair = _JsonRepairFallback() + +try: + CURRENT_FILE = Path(__file__).resolve() +except NameError: + CURRENT_FILE = Path.cwd() +PROJECT_ROOT = None +for parent in CURRENT_FILE.parents: + if (parent / "manage.py").exists(): + PROJECT_ROOT = parent + break + +if PROJECT_ROOT is not None: + project_root_str = str(PROJECT_ROOT) + if project_root_str not in sys.path: + sys.path.insert(0, project_root_str) + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shikshalokam_mohini.settings") + +import django + +django.setup() + +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, CompanyChat, LLMProvider, Story, StoryTranslation +from chatbot.utils.chat_utils import get_guided_chat + +logger = logging.getLogger("django") + +DEFAULT_STAGE_NAME = "SOLUTIONS" +DEFAULT_BOT_ROUTE = "/fix-solutions-bot" +DEFAULT_MAX_WORKERS = 4 + + +def _load_json_file(file_path: str) -> Any: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def load_session_ids(input_json_file: str) -> List[str]: + """Load session IDs from JSON.""" + data = _load_json_file(input_json_file) + + session_ids: List[str] = [] + if isinstance(data, list): + session_ids = [str(item).strip() for item in data if str(item).strip()] + elif isinstance(data, dict): + raw = data.get("session_ids", []) + if isinstance(raw, list): + session_ids = [str(item).strip() for item in raw if str(item).strip()] + + # Preserve order and de-duplicate + seen = set() + unique_ids = [] + for session_id in session_ids: + if session_id not in seen: + seen.add(session_id) + unique_ids.append(session_id) + + return unique_ids + + +def _try_parse_json(response: Any) -> Any: + if isinstance(response, str): + try: + return json_repair.repair_json(response, return_objects=True) + except Exception: + return response + return response + + +def unwrap_llm_response(response: Any) -> Any: + """Unwrap tool-call wrappers and nested {type: object, value: ...} structures.""" + response = _try_parse_json(response) + + if isinstance(response, dict): + extracted = response.get("parameters") or response.get("input") + if isinstance(extracted, dict): + response = extracted + + def _unwrap(obj: Any) -> Any: + if isinstance(obj, dict): + while obj.get("type") == "object" and "value" in obj: + obj = obj.get("value") or {} + return {k: _unwrap(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_unwrap(item) for item in obj] + return obj + + return _unwrap(response) + + +def normalize_solutions_discussed(value: Any) -> Optional[List[str]]: + """Coerce solutions_discussed to a clean list of strings.""" + if isinstance(value, str): + try: + parsed = json_repair.repair_json(value, return_objects=True) + except Exception: + parsed = value + if isinstance(parsed, list): + value = parsed + else: + return None + + if not isinstance(value, list): + return None + + result = [] + for item in value: + if isinstance(item, str): + item = item.replace('\\"', '"').replace("\\'", "'").strip() + result.append(item) + + return result or None + + +def extract_solutions_discussed_from_response(response: Any) -> Optional[Any]: + """Extract solutions_discussed from bot response exactly as returned by mitigation bot.""" + cleaned = unwrap_llm_response(response) + + if isinstance(cleaned, dict): + solutions_discussed = cleaned.get("solutions_discussed") + if solutions_discussed is not None: + return normalize_solutions_discussed(solutions_discussed) + + return None + + +def get_mitigation_bot(route: str = DEFAULT_BOT_ROUTE) -> CompanyBot: + bot = CompanyBot.objects.filter(route=route).first() + if not bot: + raise ValueError(f"CompanyBot with route '{route}' not found") + return bot + + +def resolve_bedrock_tools(tool_context: Any) -> Optional[Dict[str, Any]]: + """Resolve the inner Bedrock toolConfig from a bot tool_context payload.""" + if not tool_context: + return None + + if isinstance(tool_context, str): + try: + tool_context = json_repair.repair_json(tool_context, return_objects=True) + except Exception: + return None + + if isinstance(tool_context, dict): + if tool_context.get("toolConfig"): + return tool_context + + for key in ("content_tool", "story_tool", "tool"): + candidate = tool_context.get(key) + if isinstance(candidate, dict) and candidate.get("toolConfig"): + return candidate + + for value in tool_context.values(): + if isinstance(value, dict) and value.get("toolConfig"): + return value + + if isinstance(tool_context, list) and tool_context: + for item in tool_context: + if isinstance(item, dict) and item.get("toolConfig"): + return item + + return None + + +def get_solutions_stage_chats( + session_id: str, + stage_name: str = DEFAULT_STAGE_NAME, +) -> List[CompanyChat]: + """Return all chats for the requested stage in chronological order.""" + chats = list(CompanyChat.objects.filter(session=session_id).order_by("created_at")) + if not chats: + return [] + + return [c for c in chats if c.stage == stage_name] + + +def call_temporary_mitigation_bot( + mitigation_bot: CompanyBot, + relevant_chats: List[CompanyChat], +) -> Any: + """Call mitigation bot with the provided chat slice.""" + messages = get_guided_chat(company_bot=mitigation_bot, company_chats=relevant_chats) + + if mitigation_bot.provider == LLMProvider.BEDROCK_CONVERSE: + tools = resolve_bedrock_tools(mitigation_bot.tool_context) + + system_prompt = [{"text": mitigation_bot.context}] if mitigation_bot.context else None + + return handle_bedrock_model( + system_prompt=system_prompt, + messages=messages, + model_name=mitigation_bot.llm_model, + temperature=mitigation_bot.bot_temperature, + max_token=mitigation_bot.max_token, + company_bot=mitigation_bot, + tools=tools, + ) + + if mitigation_bot.provider == LLMProvider.OPENAI: + system_prompt = [ + { + "role": "system", + "content": mitigation_bot.context or "", + } + ] + return handle_openai_model( + system_prompt=system_prompt, + messages=messages, + model_name=mitigation_bot.llm_model, + temperature=mitigation_bot.bot_temperature, + max_token=mitigation_bot.max_token, + is_json_response=True, + ) + + raise ValueError(f"Unsupported provider for mitigation bot: {mitigation_bot.provider}") + + +def update_story_and_translations( + story: Story, + solutions_discussed: Any, + dry_run: bool = True, +) -> Tuple[bool, bool]: + """Update story.other_params and all translations. Returns (story_updated, translations_updated).""" + story_updated = False + translations_updated = False + + other_params = story.other_params if isinstance(story.other_params, dict) else {} + current = other_params.get("solutions_discussed") + + if current != solutions_discussed: + other_params["solutions_discussed"] = solutions_discussed + story.other_params = other_params + story_updated = True + if not dry_run: + story.save(update_fields=["other_params"]) + + translations = StoryTranslation.objects.filter(story=story) + for tr in translations: + tr_other_params = tr.other_params if isinstance(tr.other_params, dict) else {} + if tr_other_params.get("solutions_discussed") != solutions_discussed: + tr_other_params["solutions_discussed"] = solutions_discussed + tr.other_params = tr_other_params + translations_updated = True + if not dry_run: + tr.save(update_fields=["other_params"]) + + return story_updated, translations_updated + + +def process_single_session( + session_id: str, + mitigation_bot: CompanyBot, + stage_name: str = DEFAULT_STAGE_NAME, + dry_run: bool = True, +) -> Dict[str, Any]: + """Process one session and update solutions_discussed for guest-discussion story only.""" + result = { + "session": session_id, + "status": "skipped", + "reason": "", + "story_id": None, + "solutions_discussed": None, + "story_updated": False, + "translations_updated": False, + } + + try: + story = Story.objects.filter(session=session_id).first() + if not story: + result["reason"] = "story_not_found" + return result + + result["story_id"] = story.id + + other_params = story.other_params if isinstance(story.other_params, dict) else {} + if other_params.get("flow") != "guest-discussion": + result["reason"] = "not_guest_discussion" + return result + + relevant_chats = get_solutions_stage_chats( + session_id=session_id, + stage_name=stage_name, + ) + if not relevant_chats: + result["reason"] = "no_relevant_chats" + return result + + llm_response = call_temporary_mitigation_bot( + mitigation_bot=mitigation_bot, + relevant_chats=relevant_chats, + ) + + new_solutions_discussed = extract_solutions_discussed_from_response(llm_response) + if not new_solutions_discussed: + result["reason"] = "solutions_discussed_not_found_in_bot_response" + return result + + story_updated, translations_updated = update_story_and_translations( + story=story, + solutions_discussed=new_solutions_discussed, + dry_run=dry_run, + ) + + result["solutions_discussed"] = new_solutions_discussed + result["story_updated"] = story_updated + result["translations_updated"] = translations_updated + result["status"] = "updated" if (story_updated or translations_updated) else "no_change" + return result + + except Exception as e: + logger.exception("Error while processing session %s", session_id) + result["status"] = "failed" + result["reason"] = str(e) + return result + + +def process_sessions_from_json( + input_json_file: str, + dry_run: bool = True, + bot_route: str = DEFAULT_BOT_ROUTE, + stage_name: str = DEFAULT_STAGE_NAME, + max_workers: int = DEFAULT_MAX_WORKERS, +) -> Dict[str, Any]: + """Main entrypoint.""" + if not os.path.exists(input_json_file): + raise FileNotFoundError(f"Input JSON file not found: {input_json_file}") + + session_ids = load_session_ids(input_json_file) + if not session_ids: + return { + "dry_run": dry_run, + "total_sessions": 0, + "updated": 0, + "no_change": 0, + "skipped": 0, + "failed": 0, + "results": [], + } + + mitigation_bot = get_mitigation_bot(route=bot_route) + max_workers = max(1, int(max_workers or 1)) + + results: List[Dict[str, Any]] = [] + summary = { + "dry_run": dry_run, + "total_sessions": len(session_ids), + "updated": 0, + "no_change": 0, + "skipped": 0, + "failed": 0, + "results": results, + } + + logger.info( + "Starting solutions_discussed mitigation for %s sessions. dry_run=%s", + len(session_ids), + dry_run, + ) + + def run_one(idx_and_session: Tuple[int, str]) -> Tuple[int, Dict[str, Any]]: + idx, session_id = idx_and_session + row = process_single_session( + session_id=session_id, + mitigation_bot=mitigation_bot, + stage_name=stage_name, + dry_run=dry_run, + ) + return idx, row + + indexed_results: List[Tuple[int, Dict[str, Any]]] = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(run_one, (idx, session_id)) + for idx, session_id in enumerate(session_ids) + ] + + for future in as_completed(futures): + idx, row = future.result() + indexed_results.append((idx, row)) + + if row["status"] == "updated": + summary["updated"] += 1 + elif row["status"] == "no_change": + summary["no_change"] += 1 + elif row["status"] == "failed": + summary["failed"] += 1 + else: + summary["skipped"] += 1 + + for _, row in sorted(indexed_results, key=lambda item: item[0]): + results.append(row) + + logger.info( + "Mitigation completed. total=%s updated=%s no_change=%s skipped=%s failed=%s dry_run=%s", + summary["total_sessions"], + summary["updated"], + summary["no_change"], + summary["skipped"], + summary["failed"], + summary["dry_run"], + ) + + return summary + + +def _setup_django() -> None: + """Bootstrap Django so this script can be executed directly from terminal.""" + try: + current = Path(__file__).resolve() + except NameError: + current = Path.cwd() + project_root = None + + for parent in current.parents: + if (parent / "manage.py").exists(): + project_root = parent + break + + if project_root is None: + raise RuntimeError("Could not locate project root containing manage.py") + + project_root_str = str(project_root) + if project_root_str not in sys.path: + sys.path.insert(0, project_root_str) + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shikshalokam_mohini.settings") + + import django + + django.setup() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Mitigate solutions_discussed for guest-discussion stories using /fix-solutions-bot." + ) + parser.add_argument( + "--input-json", + required=True, + help="Path to input JSON containing session IDs.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without saving to DB.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Apply updates to DB (overrides --dry-run).", + ) + parser.add_argument( + "--bot-route", + default=DEFAULT_BOT_ROUTE, + help="Mitigation bot route. Default: /fix-solutions-bot", + ) + parser.add_argument( + "--stage-name", + default=DEFAULT_STAGE_NAME, + help="Stage name for solutions context. Default: SOLUTIONS", + ) + parser.add_argument( + "--max-workers", + type=int, + default=DEFAULT_MAX_WORKERS, + help="ThreadPoolExecutor worker count. Default: 4", + ) + return parser.parse_args() + + +if __name__ == "__main__": + _setup_django() + args = _parse_args() + dry_run = False if args.apply else True + if args.dry_run: + dry_run = True + + summary = process_sessions_from_json( + input_json_file=args.input_json, + dry_run=dry_run, + bot_route=args.bot_route, + stage_name=args.stage_name, + max_workers=args.max_workers, + ) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) diff --git a/chatbot/scripts/guest_discussion/post_processing/report_script.py b/chatbot/scripts/guest_discussion/post_processing/report_script.py new file mode 100644 index 0000000..a7e622f --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/report_script.py @@ -0,0 +1,151 @@ +from typing import List, Dict, Any +from tqdm import tqdm +from chatbot.models import CompanyBot +import json +import os +import json_repair +from retrying import retry +from concurrent.futures import ThreadPoolExecutor, as_completed +from chatbot.utils.llm import LLM +from chatbot.models.enums import LLMProvider +from chatbot.llm_models.llm_script import handle_bedrock_model + + +# -------------- CONFIG ------------------ +INPUT_FILE = 'chatbot/scripts/report/ReportList.json' +OUTPUT_FILE = 'chatbot/scripts/report/final_output.json' +MAX_WORKERS = 4 +BATCH_SIZE = 20 +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +# -------------- LLM CALL ------------------ + +def build_prompt(challenges: List[str], solutions: List[str]) -> List[Dict[str, Any]]: + challenge_str = "\n".join([f"{i+1}. {c}" for i, c in enumerate(challenges)]) + solution_str = "\n".join([f"{i+1}. {s}" for i, s in enumerate(solutions)]) + + message = f""" + These are the challenges and solution from the report: +CHALLENGES: +{challenge_str} + +SOLUTIONS: +{solution_str} +""" + return [{"role": "user", "content": [{"text": message.strip()}]}] + + +def chunk_data(data: List[Any], batch_size: int) -> List[List[Any]]: + return [data[i:i + batch_size] for i in range(0, len(data), batch_size)] + + +def process_stories_parallel(stories: List[Dict[str, Any]]) -> None: + results = {} + + # Load existing output + if os.path.exists(OUTPUT_FILE): + with open(OUTPUT_FILE, "r") as f: + try: + results = json.load(f) + except json.JSONDecodeError: + print("⚠️ Output file is corrupted. Starting fresh.") + + processed_ids = set(results.keys()) + remaining_stories = [s for s in stories if s["id"] not in processed_ids] + story_batches = chunk_data(remaining_stories, BATCH_SIZE) + + print(f"🔧 Processing {len(story_batches)} batches with {MAX_WORKERS} workers (batch size = {BATCH_SIZE})...") + + def process_one_batch(batch: List[Dict[str, Any]]) -> Dict[str, Any]: + batch_results = {} + for story in batch: + story_result = call_llm_for_story(story) + batch_results.update(story_result) + return batch_results + + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + futures = [executor.submit(process_one_batch, batch) for batch in story_batches] + + for future in tqdm(as_completed(futures), total=len(futures), desc="Processing Story Batches"): + batch_output = future.result() + results.update(batch_output) + + with open(OUTPUT_FILE, "w") as f: + json.dump(results, f, indent=2) + + print(f"✅ Output saved to {OUTPUT_FILE}") + + +def call_llm_for_story(story: Dict[str, Any]) -> Dict[str, Any]: + story_id = story["id"] + data = json.loads(story["data"]) + challenges = data.get("challenges", []) + solutions = data.get("solutions", []) + + if not challenges or not solutions: + return {story_id: {"reorder_steps": []}} + + messages = build_prompt(challenges, solutions) + company_bot = CompanyBot.objects.filter(route='/script_report').first() + if not company_bot: + return {story_id: {"error": "No bot found"}} + + tools = company_bot.tool_context + if tools and isinstance(tools, str): + tools = json_repair.repair_json(tools, return_objects=True) + + formatted_prompt = [{"text": company_bot.context}] + response = handle_bedrock_model( + system_prompt=formatted_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + cleaned = get_clean_output(response=response) + return {story_id: {"reorder_steps": cleaned}} + +# -------------- MAIN ------------------ + +def run_story_matcher(input_file: str = INPUT_FILE): + with open(input_file, "r") as f: + data = json.load(f) + + print(f"🚀 Loaded {len(data)} stories") + process_stories_parallel(data) + + + +def retry_if_result_none(result): + return result is None + +def get_clean_output(response): + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response.get('reorder_steps') + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + # print("response_json_content: ", response_json_content) + + return response_json_content \ No newline at end of file diff --git a/chatbot/scripts/guest_discussion/post_processing/solution_script.py b/chatbot/scripts/guest_discussion/post_processing/solution_script.py new file mode 100644 index 0000000..0d8b312 --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/solution_script.py @@ -0,0 +1,448 @@ +import logging +from typing import List, Dict, Any, Optional, Tuple +from collections import defaultdict +from tqdm import tqdm +from jinja2 import Template +from chatbot.models import CompanyBot +import json +import os +import json_repair +from retrying import retry +from concurrent.futures import ThreadPoolExecutor, as_completed +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.constants.post_processing_constants import SOLUTION_CATEGORIES + +logger = logging.getLogger('django') + + +# -------------- CONFIG ------------------ +INPUT_FILE = 'chatbot/scripts/solutions/all_solutions.json' +OUTPUT_FILE = 'chatbot/scripts/solutions/llm_unique_solutions_output.json' +SECOND_OUTPUT_FILE = 'chatbot/scripts/solutions/flat_solutions_output.json' +DEFAULT_BATCH_SIZE = 5 +DEFAULT_MAX_WORKERS = 2 +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', '3')) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + +# -------------- CORE FUNCTIONS ------------------ + +def chunk_data(data: List[Dict[str, Any]], batch_size: int) -> List[List[Dict[str, Any]]]: + """Split list of solution dicts into batches.""" + return [data[i:i + batch_size] for i in range(0, len(data), batch_size)] + + +def build_user_message(batch: List[Dict[str, Any]], company_bot) -> List[Dict[str, Any]]: + + solutions_text = "\n".join( + [f"- [count: {item['solution_count']}] {item['solution_text']}" for item in batch] + ) + + categories_list = ", ".join(SOLUTION_CATEGORIES) + + # Render the tag_context Jinja2 template with variables + context_data = { + "solutions_text": solutions_text, + "categories_list": categories_list, + } + + template = Template(company_bot.tag_context) + prompt_text = template.render(context_data) + + logger.info(f"[solution_script] Rendered prompt for batch (first 500 chars): {prompt_text[:500]}") + + return [ + { + 'role': 'user', + 'content': [{ + 'text': prompt_text + }] + } + ] + +def call_llm(batch: List[Dict[str, Any]], index: int) -> Dict[str, Any]: + try: + company_bot = CompanyBot.objects.filter(route='/solutions_script').first() + if not company_bot: + logger.error("[solution_script] CompanyBot with route '/solutions_script' not found.") + return {"solutions": None, "categories": None} + + if not company_bot.tag_context: + logger.error("[solution_script] tag_context is empty for CompanyBot route='/solutions_script'. Please set the prompt template in admin.") + return {"solutions": None, "categories": None} + + messages = build_user_message(batch, company_bot) + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + formatted_prompt = [{ + 'text': company_bot.context + }] + + output = handle_bedrock_model( + system_prompt=formatted_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool + ) + + # output = handle_openai_model( + # messages=messages, + # temperature=0.0, + # max_token=32768, + # top_p=1.0, + # model_name=LLMModel.GPT4_1_MINI, + # key_name='OPENAI_API_KEY', + # is_actual_key=False + # ) + + if output: + parsed = get_clean_output(response=output) + return parsed if parsed else {"solutions": None, "categories": None} + + return {"solutions": None, "categories": None} + except Exception as e: + print(f"Error in call_llm for batch {index}: {str(e)}") + return {"solutions": None, "categories": None} + + +def process_all_batches( + data: List[Dict[str, Any]], + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + save_to_file: bool = False, + output_file: str = OUTPUT_FILE +) -> Dict[str, Any]: + chunks = chunk_data(data, batch_size) + results = {} + + print(f"🚀 Starting processing of {len(chunks)} batches with {max_workers} workers...") + + def run_one_batch(idx_batch): + idx, batch = idx_batch + print(f"[Worker] Running batch {idx}") + result = call_llm(batch, idx) + return idx, result + + # Parallel execution + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(run_one_batch, (i, chunk)) for i, chunk in enumerate(chunks)] + + for future in tqdm(as_completed(futures), total=len(futures), desc="Batches Completed"): + idx, result = future.result() + batch_key = f"solution_{idx}" + results[batch_key] = { + "solutions": result.get("solutions"), + "categories": result.get("categories") + } + + if save_to_file: + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + print(f"✅ Saved {len(chunks)} batches to {output_file}") + + return results + + +# -------------- ENTRY POINT ------------------ +def run_unique_solution_processing( + start: int = 0, + end: int = None, + input_file: str = None, + input_data: List[Dict[str, Any]] = None, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + save_to_file: bool = False, + output_file: str = OUTPUT_FILE, + second_output_file: str = SECOND_OUTPUT_FILE +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + # Get solutions from input_data or input_file + if input_data is not None: + solutions = input_data + elif input_file: + with open(input_file, "r") as f: + raw = json.load(f) + solutions = _ensure_solution_dicts(raw) + else: + input_file = INPUT_FILE + with open(input_file, "r") as f: + raw = json.load(f) + solutions = _ensure_solution_dicts(raw) + + total = len(solutions) + end = end if end is not None else total + selected_solutions = solutions[start:end] + + print(f"🚀 Loaded {len(selected_solutions)} solutions from index {start} to {end} (Total available: {total})") + + # Process batches + batch_results = process_all_batches( + selected_solutions, + batch_size=batch_size, + max_workers=max_workers, + save_to_file=save_to_file, + output_file=output_file + ) + + # Combine batch results (pass expected_total for cross-batch normalization) + expected_total = sum(c.get('solution_count', 1) for c in selected_solutions) + combined = combine_batch_results( + batch_results=batch_results, + expected_total=expected_total, + save_to_file=save_to_file, + save_file_path=second_output_file + ) + + return batch_results, combined + + +def _ensure_solution_dicts(data: list) -> List[Dict[str, Any]]: + """Convert raw data (strings or dicts) into the standard solution dict format.""" + result = [] + if not isinstance(data, list): + return result + for item in data: + if isinstance(item, str) and item.strip(): + result.append({ + 'solution_text': item.strip(), + 'solution_count': 1, + 'category': '' + }) + elif isinstance(item, dict): + # Support both old {'solution': '...'} and new {'solution_text': '...'} formats + text = item.get('solution_text') or item.get('solution') or '' + if isinstance(text, str) and text.strip(): + result.append({ + 'solution_text': text.strip(), + 'solution_count': item.get('solution_count', 1), + 'category': item.get('category', '') + }) + return result + + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response) -> Optional[Dict[str, Any]]: + """Parse LLM response and extract solutions + categories.""" + try: + if isinstance(response, str): + try: + response = json_repair.repair_json(response, return_objects=True) + except Exception: + return None + + # Unwrap tool-call wrappers + if isinstance(response, dict): + if 'type' in response and 'value' in response: + return get_clean_output(response.get('value')) + + # Unwrap common wrapper keys + if 'parameters' in response: + return get_clean_output(response.get('parameters')) + if 'input' in response and 'unique_solutions' not in response: + return get_clean_output(response.get('input')) + + # --- Main parsing: expect {unique_solutions: [...], categories: [...]} --- + # LLM sometimes uses 'solutions' instead of 'unique_solutions' + raw_solutions = response.get('unique_solutions') or response.get('solutions') + raw_categories = response.get('categories') + + if raw_solutions is not None: + solutions = _parse_solution_items(raw_solutions) + categories = _parse_category_items(raw_categories) + if solutions: + return {"solutions": solutions, "categories": categories} + + # Handle case where LLM returns a plain list (backward compat) + if isinstance(response, list): + solutions = _parse_solution_items(response) + if solutions: + return {"solutions": solutions, "categories": []} + + return None + except Exception as e: + print(f"Error in get_clean_output: {str(e)}") + return None + + +def _parse_solution_items(data) -> List[Dict[str, Any]]: + """Parse a list of solution items from LLM output. + Handles both actual lists and stringified JSON arrays from tool-use responses. + """ + # Handle stringified JSON (LLM sometimes returns arrays as strings in tool-use) + if isinstance(data, str): + try: + data = json_repair.repair_json(data, return_objects=True) + except Exception: + return [] + + if not isinstance(data, list): + return [] + + cleaned = [] + for item in data: + if isinstance(item, dict): + text = item.get('solution_text', '') + if isinstance(text, str) and text.strip(): + cleaned.append({ + 'solution_text': text.strip(), + 'solution_count': item.get('solution_count', 1), + 'category': item.get('category', '') + }) + elif isinstance(item, str) and item.strip(): + # Backward compat: plain string → wrap as dict with count 1 + cleaned.append({ + 'solution_text': item.strip(), + 'solution_count': 1, + 'category': '' + }) + return cleaned + + +def _parse_category_items(data) -> List[Dict[str, Any]]: + """Parse category count items from LLM output. + Handles: list of {category_name, category_count}, dict of {name: count}, + and stringified JSON versions of both. + """ + # Handle stringified JSON + if isinstance(data, str): + try: + data = json_repair.repair_json(data, return_objects=True) + except Exception: + return [] + + # Handle dict format: {"Solution Proposals": 83, ...} + if isinstance(data, dict): + cleaned = [] + for name, count in data.items(): + if name and isinstance(count, (int, float)): + cleaned.append({ + 'category_name': str(name), + 'category_count': int(count) + }) + return cleaned + + if not isinstance(data, list): + return [] + + cleaned = [] + for item in data: + if isinstance(item, dict): + name = item.get('category_name', '') + count = item.get('category_count', 0) + if name: + cleaned.append({ + 'category_name': str(name), + 'category_count': int(count) if count else 0 + }) + return cleaned + + + +def _normalize_solution_counts(solutions: List[Dict[str, Any]], expected_total: int) -> List[Dict[str, Any]]: + """Normalize solution counts so they sum to expected_total (scales both up and down).""" + if not solutions or expected_total <= 0: + return solutions + + actual_total = sum(c.get('solution_count', 1) for c in solutions) + if actual_total == expected_total or actual_total == 0: + return solutions + + ratio = expected_total / actual_total + for c in solutions: + c['solution_count'] = max(1, round(c['solution_count'] * ratio)) + + # Fix any rounding drift (±1 or ±2) by adjusting the largest items + diff = expected_total - sum(c['solution_count'] for c in solutions) + solutions.sort(key=lambda c: c['solution_count'], reverse=True) + for c in solutions: + if diff == 0: + break + adj = 1 if diff > 0 else -1 + if c['solution_count'] + adj >= 1: + c['solution_count'] += adj + diff -= adj + + return solutions + +def combine_batch_results( + batch_results: Dict[str, Any] = None, + expected_total: int = None, + output_file_path: str = OUTPUT_FILE, + save_to_file: bool = False, + save_file_path: str = SECOND_OUTPUT_FILE +) -> Dict[str, Any]: + """ + Combine batch-wise LLM output into a single result. + If expected_total is provided, normalizes combined counts to match it. + """ + all_solutions = [] + category_counts = defaultdict(int) + + # Use provided batch_results or load from file + if batch_results is not None: + solutions_dict = batch_results + else: + if not os.path.exists(output_file_path): + print(f"⚠️ Warning: Output file {output_file_path} not found.") + return {"solutions": [], "category_counts": {}} + + try: + with open(output_file_path, "r") as f: + solutions_dict = json.load(f) + except Exception as e: + print(f"❌ Error reading {output_file_path}: {e}") + return {"solutions": [], "category_counts": {}} + + # Combine solutions and aggregate category counts from all batches + for _, batch_data in solutions_dict.items(): + if not isinstance(batch_data, dict): + continue + + # Collect solutions + solutions_list = batch_data.get("solutions") + if isinstance(solutions_list, list): + for solution in solutions_list: + if isinstance(solution, dict) and solution.get('solution_text', '').strip(): + all_solutions.append({ + 'solution_text': solution['solution_text'].strip(), + 'solution_count': solution.get('solution_count', 1), + 'category': solution.get('category', '') + }) + + # Normalize solution counts to match expected_total (once, at combine level) + if expected_total is not None: + all_solutions = _normalize_solution_counts(all_solutions, expected_total) + + # Compute category counts from the normalized solution items (so both are consistent) + for solution in all_solutions: + cat = solution.get('category', '') + if cat: + category_counts[cat] += solution.get('solution_count', 1) + + # Save if requested + if save_to_file: + try: + save_data = { + "solutions": all_solutions, + "category_counts": dict(category_counts) + } + with open(save_file_path, "w") as f: + json.dump(save_data, f, indent=2) + print(f"✅ Combined results saved to {save_file_path}") + except Exception as e: + print(f"❌ Error saving file: {e}") + + print(f"Combined solutions count: {len(all_solutions)}") + return { + "solutions": all_solutions, + "category_counts": dict(category_counts) + } + + +if __name__ == "__main__": + run_unique_solution_processing() diff --git a/chatbot/scripts/guest_discussion/post_processing/village_data_cleaning.py b/chatbot/scripts/guest_discussion/post_processing/village_data_cleaning.py new file mode 100644 index 0000000..5a703d4 --- /dev/null +++ b/chatbot/scripts/guest_discussion/post_processing/village_data_cleaning.py @@ -0,0 +1,526 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import CompanyBot, Story, StoryTranslation, ChatSession +from concurrent.futures import ThreadPoolExecutor, as_completed +from django.db import transaction +from tqdm import tqdm +from typing import List, Dict, Any +import json +import json_repair +import logging +import os + +# -------------- CONFIG ------------------ +MASTER_VILLAGES_FILE = 'chatbot/scripts/guest_discussion/master_villages.json' +MAX_WORKERS = 4 +BATCH_SIZE = 20 +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + +logger = logging.getLogger('django') + + +# -------------- LLM CALL ------------------ + +def build_prompt(location: str, master_villages: Dict[str, List[str]]) -> List[Dict[str, Any]]: + """Build prompt for village name mapping""" + + # Format the master villages data for the prompt + villages_text = "" + for district, villages in master_villages.items(): + villages_list = ", ".join(villages) + villages_text += f"\n{district.upper()} district: {villages_list}" + + message = f""" + MASTER VILLAGES LIST (by district): + {villages_text} + + LOCATION TO MAP: "{location}" + """ + + return [{"role": "user", "content": [{"text": message.strip()}]}] + + +def chunk_data(data: List[Any], batch_size: int) -> List[List[Any]]: + """Split data into chunks for parallel processing""" + return [data[i:i + batch_size] for i in range(0, len(data), batch_size)] + + +def load_master_villages() -> Dict[str, List[str]]: + """Load master villages JSON file""" + try: + with open(MASTER_VILLAGES_FILE, "r", encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + print(f"❌ Master villages file not found: {MASTER_VILLAGES_FILE}") + return {} + except json.JSONDecodeError: + print(f"❌ Invalid JSON in master villages file: {MASTER_VILLAGES_FILE}") + return {} + + +def process_stories_parallel(stories: List[Story], master_villages: Dict[str, List[str]]) -> Dict[str, List[int]]: + """Process stories in parallel batches""" + + if not master_villages: + print("❌ No master villages data available") + return {"skipped_no_location": [], "failed_village_mapping": []} + + # Filter stories and track skipped ones + stories_to_process = [] + skipped_no_location = [] + + for story in stories: + if story.other_params: + # Only check location from other_params, NOT from english_json + location = story.other_params.get('location') + + if not location: + skipped_no_location.append(story.id) + continue + + # Check if village mapping already exists + if 'village' not in story.other_params: + stories_to_process.append(story) + + print(f"📊 Summary:") + print(f" - Total stories: {len(stories)}") + print(f" - Skipped (no location): {len(skipped_no_location)}") + print(f" - To process: {len(stories_to_process)}") + + if not stories_to_process: + print("✅ No stories need village mapping") + return { + "skipped_no_location": skipped_no_location, + "failed_village_mapping": [] + } + + story_batches = chunk_data(stories_to_process, BATCH_SIZE) + + print( + f"🔧 Processing {len(stories_to_process)} stories in {len(story_batches)} batches with {MAX_WORKERS} workers (batch size = {BATCH_SIZE})...") + + def process_one_batch(batch: List[Story]) -> Dict[str, List]: + batch_results = [] + batch_failed = [] + for story in batch: + result = call_llm_for_village_mapping(story, master_villages) + if result: + batch_results.append(result) + else: + batch_failed.append(story.id) + return {"successful": batch_results, "failed": batch_failed} + + all_updates = [] + failed_village_mapping = [] + + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + futures = [executor.submit(process_one_batch, batch) for batch in story_batches] + + for future in tqdm(as_completed(futures), total=len(futures), desc="Processing Story Batches"): + batch_output = future.result() + all_updates.extend(batch_output["successful"]) + failed_village_mapping.extend(batch_output["failed"]) + + # Bulk update stories + update_stories_in_db(all_updates) + + # Final summary + print(f"\n📋 FINAL SUMMARY:") + print(f" ✅ Successfully processed: {len(all_updates)} stories") + print(f" ⚠️ Skipped (no location): {len(skipped_no_location)} stories") + print(f" ❌ Failed village mapping: {len(failed_village_mapping)} stories") + + return { + "skipped_no_location": skipped_no_location, + "failed_village_mapping": failed_village_mapping + } + + +def call_llm_for_village_mapping(story: Story, master_villages: Dict[str, List[str]]) -> Dict[str, Any]: + """Call LLM to map story location to village name""" + + try: + # Get location ONLY from other_params, NOT from english_json + location = '' + if story.other_params: + location = story.other_params.get('location', '') + + if not location: + print(f"⚠️ Story {story.id}: No location found in other_params") + return None + + messages = build_prompt(location, master_villages) + company_bot = CompanyBot.objects.filter(route='/script_village_mapping').first() + + if not company_bot: + print("❌ No bot found for route '/script_village_mapping'") + return None + + tools = company_bot.tool_context + if tools and isinstance(tools, str): + tools = json_repair.repair_json(tools, return_objects=True) + + formatted_prompt = [{"text": company_bot.context}] + response = handle_bedrock_model( + system_prompt=formatted_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + print("----------------------------------") + print("response: ", response) + cleaned_response = get_clean_output(response=response) + print("cleaned_response: ", cleaned_response) + print("----------------------------------") + + if cleaned_response and isinstance(cleaned_response, dict): + village_name = cleaned_response.get('village', 'others') + district_name = cleaned_response.get('district', 'others') + + mapped_key = '' + for key, villages in master_villages.items(): + if village_name in villages: + mapped_key = key + break + + return { + 'story_id': story.id, + 'village': mapped_key, + 'district': mapped_key, + 'original_location': location + } + + print(f"❌ Story {story.id}: Failed to get valid response from LLM") + return None + + except Exception as e: + print(f"❌ Error processing story {story.id}: {e}") + return None + + +def get_story_language_from_session(story_id: int) -> str: + """Get the language from the story's chat session""" + try: + story = Story.objects.get(id=story_id) + + if hasattr(story, 'session'): + chat_session = ChatSession.objects.filter(session=story.session).first() + if chat_session and hasattr(chat_session, 'language'): + return chat_session.language + + # Default to story's language if no session found + return story.language + except Exception as e: + print(f"Error getting language from session for story {story_id}: {e}") + return 'en' # Default to English + + +def transliterate_field(voice_provider, message_body, target_language, source_language="en"): + """For transliteration (used for location names, districts, villages, names)""" + if not message_body or message_body == '' or source_language == target_language: + return message_body + + try: + from chatbot.utils.transliterate_utils import transliterate_text + is_sentence = ' ' in message_body + response = transliterate_text( + voice_provider=voice_provider, + message_body=message_body, + target_language=target_language, + source_language=source_language, + is_sentence=is_sentence + ) + if response.get('status') == 200: + from chatbot.utils.transliterate_utils import get_transliteration_output + data = get_transliteration_output(response.get('content')) + return data if data else response.get('content') + else: + print(f"Transliteration failed, using original text: {message_body}") + return message_body + except Exception as e: + print(f"Error transliterating text '{message_body}': {str(e)}") + return message_body + + +def update_story_translations(story_id: int, village: str, district: str, voice_provider: str = "google") -> None: + """Update StoryTranslation with transliterated village and district names""" + try: + story = Story.objects.get(id=story_id) + + # Get language from chat session + session_language = get_story_language_from_session(story_id) + + # Get all existing translations for this story + translations = StoryTranslation.objects.filter(story=story) + + for translation in translations: + # Transliterate village and district names + if village and str(village).lower() not in ['others', 'other']: + transliterated_village = transliterate_field( + voice_provider=voice_provider, + message_body=village, + target_language=translation.language, + source_language=session_language + ) + else: + transliterated_village = village + + if district and str(district).lower() not in ['others', 'other']: + transliterated_district = transliterate_field( + voice_provider=voice_provider, + message_body=district, + target_language=translation.language, + source_language=session_language + ) + else: + transliterated_district = district + + # Update translation's other_params (get_or_update, don't create) + if not translation.other_params: + translation.other_params = {} + + translation.other_params['village'] = transliterated_village + translation.other_params['district'] = transliterated_district + + translation.save(update_fields=['other_params']) + + print(f"✅ Updated translation for story {story_id} in {translation.language}: " + f"{village} -> {transliterated_village}, {district} -> {transliterated_district}") + + except Story.DoesNotExist: + print(f"❌ Story {story_id} not found for translation update") + except Exception as e: + print(f"❌ Error updating translations for story {story_id}: {e}") + + +def update_stories_in_db(updates: List[Dict[str, Any]]) -> None: + """Bulk update stories in database""" + + try: + with transaction.atomic(): + for update in updates: + story_id = update['story_id'] + village = update['village'] + district = update['district'] + + try: + story = Story.objects.get(id=story_id) + if not story.other_params: + story.other_params = {} + + # Update ONLY in other_params, NOT in english_json + story.other_params['village'] = village + story.other_params['district'] = district + + story.save(update_fields=['other_params']) + + print(f"✅ Updated story {story_id}: {update['original_location']} -> {village}, {district}") + + # Update translations with transliterated values + update_story_translations(story_id, village, district) + + except Story.DoesNotExist: + print(f"❌ Story {story_id} not found") + except Exception as e: + print(f"❌ Error updating story {story_id}: {e}") + + except Exception as e: + print(f"❌ Database transaction error: {e}") + + +# -------------- MAIN ------------------ + +def run_village_mapper(story_queryset=None, master_villages=None) -> Dict[str, List[int]]: + """Main function to run village mapping""" + + # Use passed master_villages, fallback to loading from file + if master_villages is None: + master_villages = load_master_villages() + + if not master_villages: + return {"skipped_no_location": [], "failed_village_mapping": []} + + # Get stories to process + if story_queryset is None: + stories = Story.objects.filter( + other_params__isnull=False + ).exclude( + other_params__village__isnull=False + ) + else: + stories = story_queryset + + stories_list = list(stories) + print(f"🚀 Found {len(stories_list)} stories to analyze") + + if not stories_list: + print("✅ No stories found to process") + return {"skipped_no_location": [], "failed_village_mapping": []} + + return process_stories_parallel(stories_list, master_villages) + + +# -------------- UTILITY FUNCTIONS ------------------ + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response): + """Clean and extract output from LLM response""" + print("Type of response: ", type(response)) + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + print("extracted_data: ", extracted_data, " & type: ", type(extracted_data)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response + if response_json_content and isinstance(response_json_content, str) and "{" in response_json_content: + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + return response_json_content + + +# -------------- ANALYSIS FUNCTIONS ------------------ + +def analyze_skipped_stories(story_ids: List[int]) -> None: + """Analyze stories that were skipped due to missing location""" + if not story_ids: + print("✅ No stories were skipped") + return + + stories = Story.objects.filter(id__in=story_ids) + + print(f"\n🔍 ANALYSIS OF SKIPPED STORIES ({len(story_ids)} total):") + print("=" * 50) + + for story in stories[:10]: # Show first 10 as sample + location = story.other_params.get('location', 'N/A') if story.other_params else 'N/A' + print(f"Story ID: {story.id}") + print(f" Location: {location}") + print("-" * 30) + + if len(stories) > 10: + print(f"... and {len(stories) - 10} more stories") + + +def analyze_failed_stories(story_ids: List[int]) -> None: + """Analyze stories that failed village mapping""" + if not story_ids: + print("✅ No stories failed village mapping") + return + + stories = Story.objects.filter(id__in=story_ids) + + print(f"\n🔍 ANALYSIS OF FAILED STORIES ({len(story_ids)} total):") + print("=" * 50) + + for story in stories[:10]: # Show first 10 as sample + location = story.other_params.get('location', 'N/A') if story.other_params else 'N/A' + print(f"Story ID: {story.id}") + print(f" Location: {location}") + print("-" * 30) + + if len(stories) > 10: + print(f"... and {len(stories) - 10} more stories") + + +def get_village_mapping_stats() -> Dict[str, Any]: + """Get statistics about village mappings""" + total_stories = Story.objects.filter(other_params__isnull=False).count() + + stories_with_village = Story.objects.filter( + other_params__village__isnull=False + ).count() + + # Get village distribution + village_distribution = {} + stories_with_villages = Story.objects.filter( + other_params__village__isnull=False + ).values_list('other_params', flat=True) + + for other_params in stories_with_villages: + village = other_params.get('village', 'unknown') + village_distribution[village] = village_distribution.get(village, 0) + 1 + + # Get translation stats + translations_with_village = StoryTranslation.objects.filter( + other_params__village__isnull=False + ).count() + + stats = { + 'total_stories_with_other_params': total_stories, + 'stories_with_village_mapping': stories_with_village, + 'translations_with_village': translations_with_village, + 'village_distribution': village_distribution, + 'most_common_villages': sorted(village_distribution.items(), key=lambda x: x[1], reverse=True)[:10] + } + + print(f"\n📊 VILLAGE MAPPING STATISTICS:") + print("=" * 40) + print(f"Total stories with other_params: {stats['total_stories_with_other_params']}") + print(f"Stories with village mapping: {stats['stories_with_village_mapping']}") + print(f"Translations with village mapping: {stats['translations_with_village']}") + print(f"Unique villages mapped: {len(village_distribution)}") + print(f"\nTop 10 villages:") + for village, count in stats['most_common_villages']: + print(f" {village}: {count} stories") + + return stats + + +# -------------- USAGE EXAMPLES ------------------ + +def run_for_specific_stories(story_ids: List[int], master_villages=None) -> Dict[str, List[int]]: + """Run village mapping for specific story IDs""" + if master_villages is None: + master_villages = load_master_villages() + + if not master_villages: + return {"skipped_no_location": [], "failed_village_mapping": []} + + stories = Story.objects.filter(id__in=story_ids) + summary = run_village_mapper(story_queryset=stories, master_villages = master_villages) + + # Analyze results + analyze_skipped_stories(summary['skipped_no_location']) + analyze_failed_stories(summary['failed_village_mapping']) + + return summary + + +def run_for_date_range(start_date, end_date) -> Dict[str, List[int]]: + """Run village mapping for stories in date range""" + stories = Story.objects.filter( + created_at__gte=start_date, + created_at__lte=end_date, + other_params__isnull=False + ).exclude( + other_params__village__isnull=False + ) + summary = run_village_mapper(story_queryset=stories) + + # Analyze results + analyze_skipped_stories(summary['skipped_no_location']) + analyze_failed_stories(summary['failed_village_mapping']) + + return summary \ No newline at end of file diff --git a/chatbot/scripts/guest_discussion/transfer_english_json.py b/chatbot/scripts/guest_discussion/transfer_english_json.py new file mode 100644 index 0000000..b715627 --- /dev/null +++ b/chatbot/scripts/guest_discussion/transfer_english_json.py @@ -0,0 +1,438 @@ +import logging +from django.utils.timezone import make_aware +from datetime import datetime +from chatbot.models import Story, ChatSession, ChatType, StoryTranslation, Voice, VoiceType, CompanyBot +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.utils.transliterate_utils import get_transliteration_output + +logger = logging.getLogger('django') + + +def translate_to_english(voice_provider, message_body, source_language="auto"): + """Translate any text to English""" + if not message_body or message_body == '': + return message_body + + try: + response = text_translate_provider( + voice_provider=voice_provider, + message_body=message_body, + target_language="en", + source_language=source_language + ) + if response.get('status') == 200: + return response.get('content') + else: + logger.warning(f"Translation failed, using original text: {message_body}") + return message_body + except Exception as e: + logger.error(f"Error translating text '{message_body}': {str(e)}") + return message_body + + +def transliterate_field(voice_provider, message_body, target_language, source_language="en"): + """For transliteration (used for location names, districts, villages)""" + if not message_body or message_body == '': + return message_body + + try: + from chatbot.utils.transliterate_utils import transliterate_text + is_sentence = ' ' in message_body + response = transliterate_text( + voice_provider=voice_provider, + message_body=message_body, + target_language=target_language, + source_language=source_language, + is_sentence=is_sentence + ) + if response.get('status') == 200: + data = get_transliteration_output(response.get('content')) + return data if data else response.get('content') + else: + logger.warning(f"Transliteration failed, using original text: {message_body}") + return message_body + except Exception as e: + logger.error(f"Error transliterating text '{message_body}': {str(e)}") + return message_body + + +def migrate_story_data(story): + """Migrate non-English story to English format""" + try: + if not story.other_params or 'english_json' not in story.other_params: + return f"Story ID {story.id} skipped (no english_json found)" + + english_json = story.other_params['english_json'] + original_language = story.language + + # Get company bot for voice providers + company_bot = CompanyBot.objects.filter(route='/guest-story').first() + if not company_bot: + return f"❌ No company bot found for Story ID {story.id}" + + # Get translation and transliteration providers + translate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText + ).first() + + transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate + ).first() + + # Store original story data in translation before migration + if story.language != 'en': + create_story_translation_from_original(story, company_bot, transliterate_provider, translate_provider) + + # Handle location, district, and village - use transliteration to get English versions + english_location = english_json.get('location', '') + english_district = english_json.get('district', '') + english_village = english_json.get('village', '') + + # If english_json doesn't have location/district, transliterate from original + if not english_location and story.other_params.get('location'): + english_location = transliterate_field( + voice_provider=transliterate_provider, + message_body=story.other_params['location'], + target_language="en", + source_language=original_language + ) + + if not english_district and story.other_params.get('district'): + english_district = transliterate_field( + voice_provider=transliterate_provider, + message_body=story.other_params['district'], + target_language="en", + source_language=original_language + ) + + if not english_village and story.other_params.get('village') and story.other_params['village'] != 'others': + english_village = transliterate_field( + voice_provider=transliterate_provider, + message_body=story.other_params['village'], + target_language="en", + source_language=original_language + ) + + # Handle title translation to English + english_title = english_json.get('title', '') + if not english_title and story.title: + # If no English title in english_json, translate the original title + english_title = translate_to_english( + voice_provider=translate_provider, + message_body=story.title, + source_language=original_language + ) + elif not english_title: + # Fallback to original title if no translation available + english_title = story.title + + # Create new other_params with English data + new_other_params = { + 'flow': english_json.get('flow', story.other_params.get('flow', '')), + 'village': english_village or english_json.get('village', story.other_params.get('village', '')), + 'district': english_district or english_json.get('district', story.other_params.get('district', '')), + 'location': english_location or english_json.get('location', story.other_params.get('location', '')), + 'user_name': english_json.get('user_name', ''), + 'organization': english_json.get('organization', ''), + 'discussion_date': english_json.get('discussion_date', story.other_params.get('discussion_date', '')), + 'challenges_faced': english_json.get('challenges_faced', []), + 'participants_count': english_json.get('participants_count', + story.other_params.get('participants_count', '')), + 'solutions_discussed': english_json.get('solutions_discussed', []) + } + + # Update story fields + story.title = english_title + story.other_params = new_other_params + story.location = english_location or english_json.get('location', story.other_params.get('location', '')) + story.language = 'en' + + story.save(update_fields=['title', 'other_params', 'location', 'language']) + + logger.info(f"✅ Migrated Story ID {story.id} to English") + return f"✅ Migrated Story ID {story.id} to English" + + except Exception as e: + logger.error(f"❌ Error migrating Story ID {story.id}: {str(e)}") + return f"❌ Error migrating Story ID {story.id}: {str(e)}" + + +def create_story_translation_from_original(story, company_bot, transliterate_provider, translate_provider): + """Create translation record from original story data before migration""" + try: + original_language = story.language + + # Check if translation already exists + existing_translation = StoryTranslation.objects.filter( + story=story, + language=original_language + ).first() + + if existing_translation: + logger.info(f"Translation already exists for Story ID {story.id}, language: {original_language}") + return existing_translation + + # Prepare translation data - start with original story data + translation_other_params = story.other_params.copy() if story.other_params else {} + + # Handle English fields that need transliteration to original language + english_json = translation_other_params.get('english_json', {}) + + # Transliterate English location/district/village names to original language + location_fields_to_transliterate = ['location', 'district', 'village'] + + for field in location_fields_to_transliterate: + # Check if we have English version in english_json + english_value = english_json.get(field, '') + original_value = translation_other_params.get(field, '') + + # If we have English value but original is missing or seems like English + if english_value and (not original_value or is_likely_english(original_value)): + if field == 'village' and english_value.lower() in ['others', 'other']: + # Keep 'others' as is + translation_other_params[field] = english_value + else: + # Transliterate from English to original language + transliterated_value = transliterate_field( + voice_provider=transliterate_provider, + message_body=english_value, + target_language=original_language, + source_language="en" + ) + translation_other_params[field] = transliterated_value + logger.info( + f"Transliterated {field} '{english_value}' to '{transliterated_value}' for Story ID {story.id}") + + # Handle title for translation - ensure it's in the original language + translation_title = story.title + + # Check if current title is in English and needs translation to original language + if is_likely_english(story.title) and original_language != 'en': + # Check if we have a non-English title in english_json (might be mislabeled) + if english_json.get('title') and not is_likely_english(english_json.get('title')): + translation_title = english_json.get('title') + else: + # Translate English title to original language + translation_title = text_translate_provider( + voice_provider=translate_provider, + message_body=story.title, + target_language=original_language, + source_language="en" + ).get('content', story.title) + logger.info(f"Translated title from '{story.title}' to '{translation_title}' for Story ID {story.id}") + + # Remove english_json from translation other_params + if 'english_json' in translation_other_params: + del translation_other_params['english_json'] + + # Create translation with original story data + translation = StoryTranslation.objects.create( + story=story, + language=original_language, + title=translation_title, + content=story.content or '', + blurb=story.blurb or '', + tweet=story.tweet or '', + objective=story.objective or '', + action_steps=story.action_steps or '', + impact=story.impact or '', + micro_improvement=story.micro_improvement or '', + formatted_content=story.formatted_content or '', + other_params=translation_other_params + ) + + logger.info(f"✅ Created translation for Story ID {story.id}, language: {original_language}") + return translation + + except Exception as e: + logger.error(f"❌ Error creating translation for Story ID {story.id}: {str(e)}") + return None + + +def is_likely_english(text): + """Simple check if text is likely in English (basic heuristic)""" + if not text: + return False + + # Simple heuristic: if text contains mostly ASCII characters, it's likely English + ascii_count = sum(1 for char in text if ord(char) < 128) + total_chars = len(text) + + if total_chars == 0: + return False + + # If more than 80% of characters are ASCII, consider it English + return (ascii_count / total_chars) > 0.8 + + +def get_migration_story_count(start_time=None, end_time=None): + """Get stories that need migration (non-English with english_json)""" + try: + if not start_time: + start_time = make_aware(datetime(2025, 5, 1, 0, 0)) + if not end_time: + end_time = make_aware(datetime(2025, 8, 28, 23, 59, 59)) + + # Get shikshaChaupal sessions + session_ids = list( + ChatSession.objects.filter( + session_type=ChatType.shikshaChaupal, + created_at__gt=start_time, + created_at__lt=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + + if not session_ids: + logger.info("No shikshaChaupal sessions found") + return [] + + # Get stories that are not in English but have english_json + story_ids = list( + Story.objects.filter( + session__in=session_ids, + other_params__has_key='english_json' # Has english_json + ) + .exclude(language='en') # Not already in English + .exclude(other_params=None) + .order_by('-id') + .values_list('id', flat=True) + ) + + logger.info(f"Found {len(story_ids)} stories needing migration") + if story_ids: + logger.info(f"First story ID: {story_ids[0]}, Last story ID: {story_ids[-1]}") + + return story_ids + + except Exception as e: + logger.error(f"Error getting migration story count: {str(e)}") + return [] + + +def migrate_specific_stories(story_ids): + """Migrate specific stories by their IDs""" + try: + stories = Story.objects.filter(id__in=story_ids) + + logger.info(f"Migrating {stories.count()} stories...") + print(f"Migrating {stories.count()} stories...") + + migrated_count = 0 + failed_count = 0 + + for story in stories: + result = migrate_story_data(story) + print(result) + + if "✅" in result: + migrated_count += 1 + else: + failed_count += 1 + + summary = f"Migration completed: {migrated_count} successful, {failed_count} failed" + logger.info(summary) + print(summary) + + return summary + + except Exception as e: + logger.error(f"Error in migrate_specific_stories: {str(e)}") + return f"Error in migration: {str(e)}" + + +def validate_migration_data(story_ids=None): + """Validate that migration was successful""" + try: + if story_ids: + stories = Story.objects.filter(id__in=story_ids) + else: + # Get recent shikshaChaupal stories + session_ids = list( + ChatSession.objects.filter(session_type=ChatType.shikshaChaupal) + .values_list('session', flat=True) + ) + stories = Story.objects.filter(session__in=session_ids, language='en') + + validation_results = { + 'total_checked': stories.count(), + 'english_stories': 0, + 'stories_with_translations': 0, + 'stories_without_english_json': 0, + 'issues': [] + } + + for story in stories: + if story.language == 'en': + validation_results['english_stories'] += 1 + + if story.translations.exists(): + validation_results['stories_with_translations'] += 1 + + if not story.other_params or 'english_json' in story.other_params: + validation_results['stories_without_english_json'] += 1 + validation_results['issues'].append(f"Story {story.id} still has english_json") + + logger.info(f"Validation results: {validation_results}") + return validation_results + + except Exception as e: + logger.error(f"Error in validation: {str(e)}") + return None + + +def clean_english_json_from_migrated_stories(story_ids): + """Remove english_json from migrated stories' other_params""" + try: + stories = Story.objects.filter( + id__in=story_ids, + language='en', + other_params__has_key='english_json' + ) + + cleaned_count = 0 + for story in stories: + if 'english_json' in story.other_params: + del story.other_params['english_json'] + story.save(update_fields=['other_params']) + cleaned_count += 1 + + logger.info(f"Cleaned english_json from {cleaned_count} stories") + return cleaned_count + + except Exception as e: + logger.error(f"Error cleaning english_json: {str(e)}") + return 0 + + +# Usage functions for easy execution +def run_migration_for_date_range(start_time=None, end_time=None): + """Complete migration process for a date range""" + try: + logger.info("🚀 Starting story migration process...") + + # Step 1: Get stories that need migration + story_ids = get_migration_story_count(start_time=start_time, end_time=end_time) + + if not story_ids: + logger.info("No stories found for migration") + return "No stories found for migration" + + # Step 2: Migrate the stories + result = migrate_specific_stories(story_ids) + + # Step 3: Clean up english_json from migrated stories + cleaned_count = clean_english_json_from_migrated_stories(story_ids) + + # Step 4: Validate migration + validation = validate_migration_data(story_ids) + + final_result = f"{result} | Cleaned english_json from {cleaned_count} stories | Validation: {validation}" + logger.info(f"Migration process completed: {final_result}") + + return final_result + + except Exception as e: + logger.error(f"Error in migration process: {str(e)}") + return f"Error in migration process: {str(e)}" \ No newline at end of file diff --git a/chatbot/scripts/guest_discussion/translate_script.py b/chatbot/scripts/guest_discussion/translate_script.py new file mode 100644 index 0000000..61386d1 --- /dev/null +++ b/chatbot/scripts/guest_discussion/translate_script.py @@ -0,0 +1,149 @@ +from chatbot.models import Story, Voice, VoiceType, CompanyBot, ChatSession, ChatType +from django.db import transaction +import json +from django.db.models import Q +from chatbot.utils.audio_provider_utils import text_translate_provider +from django.utils.timezone import make_aware +from datetime import datetime +import logging + +logger = logging.getLogger('django') + +###Steps To Follow: + #First step is to call get_story_count() (Adjust the date as needed) + #Second step is to call translate_specific_story_ids() and pass the story_ids we collected in First Step to + #translate stories and save english version + + +def translate_field(voice_provider, message_body, target_language, source_language="en"): + if not message_body or message_body == '': + return message_body + response = text_translate_provider( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language + ) + if response.get('status') == 200: + return response.get('content') + else: + return message_body + + +def process_story(story): + try: + other_params = story.other_params or {} + + # if 'english_json' in other_params: + # return f"Story ID {story.id} already translated." + + raw_challenges = other_params.get('challenges_faced', []) + raw_solutions = other_params.get('solutions_discussed', []) + raw_user_name = other_params.get('user_name', '') + raw_user_location = other_params.get('location', '') + raw_organization = other_params.get('organization', '') + raw_title = story.title or '' + + if isinstance(raw_challenges, str): + try: + raw_challenges = json.loads(raw_challenges) + except: + raw_challenges = [] + + if isinstance(raw_solutions, str): + try: + raw_solutions = json.loads(raw_solutions) + except: + raw_solutions = [] + + company_bot = CompanyBot.objects.get(route='/chaupal-story') + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText + ).first() + + if not voice_provider: + return f"No voice provider for Story ID {story.id} with language {story.language}" + + translated_data = { + 'title': translate_field(voice_provider, raw_title, 'en', story.language), + 'challenges_faced': [translate_field(voice_provider, c, 'en', story.language) for c in raw_challenges], + 'solutions_discussed': [translate_field(voice_provider, s, 'en', story.language) for s in raw_solutions], + 'user_name': translate_field(voice_provider, raw_user_name, 'en', story.language) if raw_user_name else '', + 'organization': translate_field(voice_provider, raw_organization, 'en', story.language) if raw_organization else '', + 'location': translate_field(voice_provider, raw_user_location, 'en', story.language) if raw_user_location else '', + 'participants_count': other_params.get('participants_count', ''), + 'discussion_date': other_params.get('discussion_date', ''), + 'flow': other_params.get('flow', None), + } + + other_params['english_json'] = translated_data + + with transaction.atomic(): + story.other_params = other_params + story.save(update_fields=['other_params']) + + return f"✅ Translated and updated Story ID {story.id}" + + except Exception as e: + return f"❌ Error in Story ID {story.id}: {str(e)}" + + +def translate_stories_to_english(start=0, end=100): + session_ids = list( + ChatSession.objects.filter(session_type=ChatType.shikshaChaupal) + .values_list('session', flat=True) + ) + + stories = Story.objects.filter(session__in=session_ids) \ + .exclude(Q(other_params=None) | Q(language='en')) \ + .order_by('-id')[start:end] + + logger.info(f"Translating stories from {start} to {end}... Total: {stories.count()}") + print(f"Translating stories from {start} to {end}... Total: {stories.count()}") + + for story in stories: + print(process_story(story)) + + +def get_translate_story_count(start_time, end_time): + if not start_time: + start_time = make_aware(datetime(2025, 7, 15, 0, 0)) + if not end_time: + end_time = make_aware(datetime(2025, 7, 28, 23, 59, 59)) + + session_ids = list( + ChatSession.objects.filter( + session_type=ChatType.shikshaChaupal, + created_at__gt=start_time, + created_at__lt=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + if session_ids: + logger.info(f"Found {len(session_ids)} sessions") + logger.info(f"First session ID: {session_ids[0]}, Last session ID: {session_ids[-1]}") + + print("First session id: ", session_ids[0]) + print("Last session id: ", session_ids[-1]) + else: + logger.info(f"No sessions found.") + print("No sessions found.") + + story_ids = list( + Story.objects.filter(session__in=session_ids) + .exclude(Q(other_params=None) | Q(language='en')) + .order_by('-id') + .values_list('id', flat=True) + ) + + logger.info(f"Total stories: {len(story_ids)}") + print(f"Total stories: {len(story_ids)}") + return story_ids + + +def translate_specific_story_ids(story_ids): + stories = Story.objects.filter(id__in=story_ids) + logger.info(f"Translating specific stories: {story_ids}... Total: {stories.count()}") + print(f"Translating specific stories: {story_ids}... Total: {stories.count()}") + + for story in stories: + print(process_story(story)) \ No newline at end of file diff --git a/chatbot/scripts/knowledge_service/__init__.py b/chatbot/scripts/knowledge_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/scripts/knowledge_service/batch_s3_reingest.py b/chatbot/scripts/knowledge_service/batch_s3_reingest.py new file mode 100644 index 0000000..a1f5ac7 --- /dev/null +++ b/chatbot/scripts/knowledge_service/batch_s3_reingest.py @@ -0,0 +1,178 @@ +import json +import os +import uuid +import requests + +from django.core.files.base import ContentFile +from django.conf import settings + +from chatbot.models import ( + CompanyBot, Media, KeyValue, Tag, Company +) +from chatbot.models.media_models import MediaImage, MediaTypeChoices + + +def process_tags(tags_list): + seen = set() + unique_tags = [] + + for tag in tags_list: + tag_name = tag.get('name') if isinstance(tag, dict) else str(tag) + if tag_name and tag_name not in seen: + seen.add(tag_name) + unique_tags.append(tag_name) + + return unique_tags + + +def get_or_create_tags(tag_names): + tag_objects = [] + for tag_name in tag_names: + tag, _ = Tag.objects.get_or_create( + name=tag_name, + defaults={'status': 'APPROVED', 'source_type': 'MANUAL'} + ) + tag_objects.append(tag) + return tag_objects + + +def attach_file_if_exists(media_obj, file_url): + if not file_url: + return + + try: + print(f"Downloading file: {file_url}") + response = requests.get(file_url, timeout=60) + response.raise_for_status() + + file_name = file_url.split('/')[-1] + media_obj.file.save( + file_name, + ContentFile(response.content), + save=False + ) + + print(f"Attached file: {file_name}") + + except Exception as e: + print(f"File download failed ({file_url}): {e}") + + +def save_media_node(media_data, company_bot, parent=None, organization=None): + media = Media( + name=media_data.get('name', ''), + media_type=media_data.get('media_type', MediaTypeChoices.TXT.value), + description=media_data.get('description', ''), + priority=media_data.get('priority', 'P1'), + company_bot_id=company_bot.id, + organization=organization, + extracted_text=media_data.get('extracted_text', ''), + parent=parent + ) + + attach_file_if_exists(media, media_data.get('file_url')) + + media.save() + print(f"Saved Media ID={media.id}, Parent={parent.id if parent else None}") + + tags = process_tags(media_data.get('tags', [])) + if tags: + media.tags.set(get_or_create_tags(tags)) + + kvs = [ + KeyValue( + media=media, + key=kv.get('key', ''), + value=kv.get('value', '') + ) + for kv in media_data.get('key_values', []) + if isinstance(kv, dict) + ] + if kvs: + KeyValue.objects.bulk_create(kvs) + + images = [ + MediaImage( + media=media, + image_url=img.get('image_url', ''), + caption=img.get('caption', '') + ) + for img in media_data.get('images', []) + if isinstance(img, dict) + ] + if images: + MediaImage.objects.bulk_create(images, ignore_conflicts=True) + + for subdoc_data in media_data.get('subdocuments', []): + save_media_node( + media_data=subdoc_data, + company_bot=company_bot, + parent=media, + organization=organization + ) + + return media + + +def batch_reingest_from_export(json_path, limit=None, start_index=0): + if not os.path.exists(json_path): + raise ValueError(f"JSON file not found: {json_path}") + + company_bot = CompanyBot.objects.get(route="/tag_extractor") + + with open(json_path) as f: + items = json.load(f) + + items = items[start_index:] + if limit: + items = items[:limit] + + session_id = str(uuid.uuid4()) + + print("=" * 60) + print("Batch Re-ingest Started") + print(f"Items: {len(items)}") + print(f"Session ID: {session_id}") + print("=" * 60) + + success = 0 + failures = [] + + for idx, media_data in enumerate(items, start=1): + print(f"\n[{idx}] Processing: {media_data.get('name')}") + + try: + organization = Company.objects.filter( + slug=media_data.get('organization') + ).first() + + save_media_node( + media_data=media_data, + company_bot=company_bot, + parent=None, + organization=organization + ) + + success += 1 + print("✓ Success") + + except Exception as e: + print(f"✗ Failed: {e}") + failures.append({ + "name": media_data.get('name'), + "error": str(e) + }) + + print("\n" + "=" * 60) + print("Batch Re-ingest Completed") + print(f"Success: {success}") + print(f"Failed: {len(failures)}") + print("=" * 60) + + return { + "session_id": session_id, + "total": len(items), + "successful": success, + "failed": len(failures), + "failures": failures + } diff --git a/chatbot/scripts/knowledge_service/docs/__init__.py b/chatbot/scripts/knowledge_service/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/scripts/knowledge_service/docs/ai_document_tag_extractor.py b/chatbot/scripts/knowledge_service/docs/ai_document_tag_extractor.py new file mode 100644 index 0000000..ad3a694 --- /dev/null +++ b/chatbot/scripts/knowledge_service/docs/ai_document_tag_extractor.py @@ -0,0 +1,261 @@ +import docx +from typing import List, Dict +from jinja2 import Template +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model +import PyPDF2 +import pandas as pd + + +def extract_text_from_file(file, file_extension: str) -> str: + """ + Extract text content from various file types + + Args: + file: File object (Django UploadedFile or similar) + file_extension: File extension (pdf, doc, docx, txt, csv, xls, xlsx) + + Returns: + Extracted text as string + """ + try: + file_extension = file_extension.lower().strip('.') + + if file_extension == 'pdf': + # Extract text from PDF + pdf_reader = PyPDF2.PdfReader(file) + text_parts = [] + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + text_parts.append(page.extract_text()) + return '\n'.join(text_parts) + + elif file_extension in ['doc', 'docx']: + # Extract text from Word document + doc = docx.Document(file) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts) + + elif file_extension == 'txt': + # Extract text from plain text file + content = file.read() + if isinstance(content, bytes): + content = content.decode('utf-8', errors='ignore') + return content + + elif file_extension == 'csv': + # Extract text from CSV + df = pd.read_csv(file) + # Convert dataframe to string representation + return df.to_string() + + elif file_extension in ['xls', 'xlsx']: + # Extract text from Excel + df = pd.read_excel(file) + # Convert dataframe to string representation + return df.to_string() + + else: + # Try to read as text for unknown file types + content = file.read() + if isinstance(content, bytes): + content = content.decode('utf-8', errors='ignore') + return content + + except Exception as e: + print(f"Error extracting text from file: {e}") + return "" + + +def extract_tags_with_bedrock(document_text: str, company_bot) -> Dict[str, List[str]]: + """ + Extract tags using Bedrock model + + Args: + document_text: Full document content + company_bot: Company bot object for Bedrock model + + Returns: + Dictionary with extracted tags and classifications + """ + try: + print("Attempting Bedrock model extraction...") + + if len(document_text) > 8000: + document_text = document_text[:8000] + "..." + + system_prompt = [ + { + 'text': company_bot.context + }, + ] + tag_context = company_bot.tag_context + if not tag_context: + return {'tags': [], 'classification': []} + + context_data = { + "document_text": document_text, + } + template = Template(tag_context) + tag_context = template.render(context_data) + + messages = [{ + 'role': 'user', + 'content': [{'text': f"{tag_context}"}] + }] + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool + ) + + print("response: ", response) + print("type: response: ", type(response)) + + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + print("last response: ", response) + print("last type: response: ", type(response)) + return response + else: + result = {'tags': [], 'classification': []} + + print(f"Bedrock extraction successful: {result}") + return { + 'tags': result.get('tags', []), + 'classification': result.get('classification', []) + } + + except Exception as e: + print(f"Bedrock extraction failed: {str(e)}") + return {'tags': [], 'classification': []} + + +def extract_tags_from_document_file(file, file_extension: str, company_bot) -> Dict[str, List[str]]: + """ + Extract tags/classification from document file using AI + + Args: + file: File object + file_extension: File extension (pdf, doc, docx, txt, csv, xls, xlsx) + company_bot: Company bot object for Bedrock model + + Returns: + Dictionary with extracted tags and classifications + """ + try: + print(f"Processing file with extension: {file_extension}") + + # Extract text from file + document_text = extract_text_from_file(file, file_extension) + + if not document_text: + print("Could not extract text from file") + return {'tags': [], 'classification': []} + + print(f"Extracted {len(document_text)} characters from file") + + # Extract tags using Bedrock + ai_result = extract_tags_with_bedrock(document_text, company_bot) + + if ai_result and (ai_result.get('tags') or ai_result.get('classification')): + print("Bedrock extraction successful") + return ai_result + + # If extraction fails, return empty + print("Tag extraction failed, returning empty results") + return {'tags': [], 'classification': []} + + except Exception as e: + print(f"Error extracting tags from file: {e}") + return {'tags': [], 'classification': []} + + +def get_tags_list_from_file(file, file_extension: str, company_bot) -> List[str]: + """ + Get tags as a clean list from file + + Args: + file: File object + file_extension: File extension + company_bot: Company bot object for Bedrock + + Returns: + List of clean tags found in the document + """ + result = extract_tags_from_document_file(file, file_extension, company_bot) + + # Combine tags and classification into one list + all_tags = [] + + if 'tags' in result: + all_tags.extend(result['tags']) + + if 'classification' in result: + all_tags.extend(result['classification']) + + # Remove duplicates while preserving order + unique_tags = [] + seen = set() + for tag in all_tags: + if tag.lower() not in seen: + unique_tags.append(tag) + seen.add(tag.lower()) + + return unique_tags + + +def get_classification_list_from_file(file, file_extension: str, company_bot) -> List[str]: + """ + Get classification items as a clean list from file + + Args: + file: File object + file_extension: File extension + company_bot: Company bot object for Bedrock + + Returns: + List of clean classification items + """ + result = extract_tags_from_document_file(file, file_extension, company_bot) + return result.get('classification', []) + + +# Enhanced one-liner functions +def extract_tags(file, file_extension: str, company_bot) -> List[str]: + """One-liner to extract clean tags from file""" + return get_tags_list_from_file(file, file_extension, company_bot) + + +def extract_classification(file, file_extension: str, company_bot) -> List[str]: + """One-liner to extract clean classification from file""" + return get_classification_list_from_file(file, file_extension, company_bot) + + +# Main function to use +def get_doc_tags_from_ai(file, file_extension, company_bot): + """ + Extract auto tags from file using AI + + Args: + file: File object (Django UploadedFile or similar) + file_extension: File extension (pdf, doc, docx, txt, csv, xls, xlsx) + company_bot: Company bot object with Bedrock configuration + + Returns: + List of extracted tags + """ + auto_tags = get_tags_list_from_file(file, file_extension, company_bot) + print(f"Tags: {auto_tags}") + return auto_tags diff --git a/chatbot/scripts/knowledge_service/docs/check_corrupted_pdf.py b/chatbot/scripts/knowledge_service/docs/check_corrupted_pdf.py new file mode 100644 index 0000000..1be90ae --- /dev/null +++ b/chatbot/scripts/knowledge_service/docs/check_corrupted_pdf.py @@ -0,0 +1,68 @@ +import os +import django +import boto3 + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shikshalokam.settings") +django.setup() + +from chatbot.models import Media + +s3 = boto3.client("s3", region_name="ap-south-1") +BUCKET = os.getenv('S3_BUCKET_NAME') + + +def is_valid_pdf(bucket, key): + try: + # Read first 1KB + head = s3.get_object( + Bucket=bucket, + Key=key, + Range="bytes=0-1023" + )["Body"].read() + + if not head.startswith(b"%PDF"): + return False, "Missing PDF header" + + # Read last 1KB + tail = s3.get_object( + Bucket=bucket, + Key=key, + Range="bytes=-1024" + )["Body"].read() + + if b"%%EOF" not in tail: + return False, "Missing PDF EOF" + + return True, None + + except Exception as e: + return False, str(e) + + +def check_corrupted_pdfs(limit=None): + qs = Media.objects.filter(file__iendswith=".pdf") + + if limit: + qs = qs[:limit] + + corrupted = [] + + print(f"\nValidating {qs.count()} PDF files...\n") + + for media in qs: + key = media.file.name + ok, reason = is_valid_pdf(BUCKET, key) + + if not ok: + corrupted.append((media.id, reason)) + print(f"❌ CORRUPTED: Media {media.id} → {reason}") + + print("\n===== SUMMARY =====") + print(f"Checked : {qs.count()}") + print(f"Corrupted : {len(corrupted)}") + + return corrupted + + +# Run +# check_corrupted_pdfs() diff --git a/chatbot/scripts/knowledge_service/docs/document_tag_extractor.py b/chatbot/scripts/knowledge_service/docs/document_tag_extractor.py new file mode 100644 index 0000000..fb79c68 --- /dev/null +++ b/chatbot/scripts/knowledge_service/docs/document_tag_extractor.py @@ -0,0 +1,370 @@ +import re +import requests +import docx +import io +from typing import List, Dict + + +def extract_tags_from_document_sections(url: str) -> Dict[str, List[str]]: + """ + Extract tags/classification directly from document sections with clean parsing + + Args: + url: Document URL (Google Docs, DOCX, etc.) + + Returns: + Dictionary with extracted tags and classifications (cleaned) + """ + + def get_document_paragraphs(url: str) -> List[str]: + """Get all paragraphs from document""" + + def get_google_doc_text(doc_url: str) -> str: + match = re.search(r'/document/d/([a-zA-Z0-9-_]+)', doc_url) + if not match: + raise ValueError("Invalid Google Docs URL") + + doc_id = match.group(1) + export_url = f"https://docs.google.com/document/d/{doc_id}/export?format=txt" + + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} + response = requests.get(export_url, headers=headers, timeout=30) + response.raise_for_status() + + # Split into paragraphs + return [p.strip() for p in response.text.split('\n') if p.strip()] + + def get_docx_paragraphs(content: bytes) -> List[str]: + doc = docx.Document(io.BytesIO(content)) + paragraphs = [] + + for para in doc.paragraphs: + text = para.text.strip() + if text: + paragraphs.append(text) + + return paragraphs + + try: + if 'docs.google.com/document' in url: + return get_google_doc_text(url) + + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + if url.lower().endswith(('.docx', '.doc')): + return get_docx_paragraphs(response.content) + else: + # Treat as text + return [p.strip() for p in response.text.split('\n') if p.strip()] + + except Exception as e: + print(f"Error reading document: {e}") + return [] + + def find_section_content(paragraphs: List[str], section_keywords: List[str]) -> List[str]: + """Find content under specific section headings""" + + content = [] + found_section = False + + for i, para in enumerate(paragraphs): + para_lower = para.lower().strip() + + # Check if this paragraph is a section heading we're looking for + is_section_header = False + for keyword in section_keywords: + if (keyword.lower() in para_lower and + len(para.split()) <= 5 and # Short heading + (para_lower.startswith(keyword.lower()) or + para_lower.endswith(keyword.lower()) or + para_lower == keyword.lower())): + is_section_header = True + found_section = True + break + + if is_section_header: + continue # Skip the header itself + + # If we found our section, collect content until next major heading + if found_section: + # Check if this is a numbered section header (e.g., "12. Next Section") + numbered_section = re.match(r'^\d+\.\s+\w+', para) + + # Only stop if we hit a numbered section that looks like a major heading + if numbered_section and len(para) > 15: # Longer numbered sections are likely new sections + break + + # Or stop if line ends with colon and has section keywords (but not list items) + if (para.endswith(':') and + not re.match(r'^\s*[\-\*•\[\]x\s]', para) and + any(keyword in para_lower for keyword in + ['section', 'chapter', 'part', 'instructions', 'purpose', + 'overview', 'background', 'audience', 'intended users', + 'complementary resources', 'limitations', 'alignment'])): + break + + # Add content from this section + content.append(para) + + return content + + def parse_list_content(content_lines: List[str]) -> List[str]: + """Parse content lines into clean list items - FIXED VERSION""" + + # Define all possible checkbox/checkmark indicators + CHECKBOX_MARKERS = [ + '[x]', '[X]', '[ ]', '✅', '☑️', '✓', '✔', '☒', '☐', '⬜', '🔲', '🔳', + ] + + # Build regex pattern from markers + # Escape special regex characters in markers + escaped_markers = [re.escape(marker) for marker in CHECKBOX_MARKERS] + markers_pattern = '|'.join(escaped_markers) + + items = [] + + for line in content_lines: + line = line.strip() + if not line: + continue + + # Check if line contains any of our markers + if any(marker in line for marker in CHECKBOX_MARKERS): + # Find all checkbox patterns in the line + # Pattern matches: optional bullet + optional space + (any marker) + space + (capture everything until another marker or end) + # Using negative lookahead to stop before next marker + checkbox_pattern = rf'[\-\*•]?\s*(?:{markers_pattern})\s*([^✅☑️✓✔☒☐⬜🔲🔳\[\]]+?)(?=[\-\*•]?\s*(?:{markers_pattern})|$)' + matches = re.findall(checkbox_pattern, line, re.IGNORECASE) + for match in matches: + item = match.strip() + if item: + items.append(item) + if matches: # If we found matches, continue to next line + continue + + # Handle other list formats... + + # Format 1: Bullet points: - item, • item, * item + bullet_match = re.match(r'^\s*[\-\*•]\s*(.+)', line) + if bullet_match: + item = bullet_match.group(1).strip() + # Remove any markers from the item + item = re.sub(rf'(?:{markers_pattern})\s*', '', item, flags=re.IGNORECASE).strip() + if item: + items.append(item) + continue + + # Format 2: Numbered lists: 1. item, 1) item + number_match = re.match(r'^\s*\d+[.)]\s*(.+)', line) + if number_match: + item = number_match.group(1).strip() + # Remove any markers from the item + item = re.sub(rf'(?:{markers_pattern})\s*', '', item, flags=re.IGNORECASE).strip() + if item: + items.append(item) + continue + + # Format 3: Comma/semicolon separated in single line + has_markers = any(marker in line for marker in CHECKBOX_MARKERS) + if (',' in line or ';' in line) and not has_markers and not any(marker in line for marker in ['-', '*']): + sub_items = re.split(r'[,;]', line) + for sub_item in sub_items: + clean_item = sub_item.strip() + if clean_item: + items.append(clean_item) + continue + + # Format 4: Plain text item (if it doesn't look like a heading) + if len(line.split()) <= 6 and line and not line.endswith(':'): + # Remove any markers + clean_line = re.sub(rf'(?:{markers_pattern})\s*', '', line, flags=re.IGNORECASE).strip() + if clean_line: + items.append(clean_line) + + # Final cleanup of items + cleaned_items = [] + for item in items: + # Remove extra whitespace + clean_item = re.sub(r'\s+', ' ', item).strip() + # Remove leading/trailing punctuation + clean_item = clean_item.strip('.,;:-') + # Remove any remaining markers + clean_item = re.sub(rf'(?:{markers_pattern})', '', clean_item, flags=re.IGNORECASE).strip() + + if clean_item and len(clean_item) > 1: + cleaned_items.append(clean_item) + + return cleaned_items + + # Main extraction logic + try: + print(f"Reading document: {url}") + + # Get all paragraphs from document + paragraphs = get_document_paragraphs(url) + print(f"Found {len(paragraphs)} paragraphs") + + result = {} + + # Look for Tags section (including "Tags / Classification") + tag_keywords = ['tags', 'tag', 'keywords', 'labels', 'tags / classification', 'classification'] + tag_content = find_section_content(paragraphs, tag_keywords) + if tag_content: + result['tags'] = parse_list_content(tag_content) + print(f"Found tags section with {len(result['tags'])} items: {result['tags']}") + + # Look for Classification section separately + classification_keywords = ['classification', 'category', 'categories', 'type', 'types'] + classification_content = find_section_content(paragraphs, classification_keywords) + if classification_content: + result['classification'] = parse_list_content(classification_content) + print( + f"Found classification section with {len(result['classification'])} items: {result['classification']}") + + return result + + except Exception as e: + print(f"Error extracting sections: {e}") + return {} + + +def get_tags_list(url: str) -> List[str]: + """ + Simple function to get just the tags as a clean list + + Args: + url: Document URL + + Returns: + List of clean tags found in the document + """ + + result = extract_tags_from_document_sections(url) + + # Combine tags and classification into one list + all_tags = [] + + if 'tags' in result: + all_tags.extend(result['tags']) + + if 'classification' in result: + all_tags.extend(result['classification']) + + # Remove duplicates while preserving order + unique_tags = [] + seen = set() + for tag in all_tags: + if tag.lower() not in seen: + unique_tags.append(tag) + seen.add(tag.lower()) + + return unique_tags + + +def get_classification_list(url: str) -> List[str]: + """ + Get just the classification items as a clean list + + Args: + url: Document URL + + Returns: + List of clean classification items + """ + + result = extract_tags_from_document_sections(url) + return result.get('classification', []) + + +# Simple one-liner functions +def extract_tags(url: str) -> List[str]: + """One-liner to extract clean tags from document""" + return get_tags_list(url) + + +def extract_classification(url: str) -> List[str]: + """One-liner to extract clean classification from document""" + return get_classification_list(url) + + +# Test function with your exact format +def test_checkbox_parsing(): + """Test the fixed parsing logic""" + + # Test cases that match your document format + test_lines = [ + "- [x] Classroom Culture", + "- [x] Systems Change", + "- [x] FLN", + "[x] M&E", + "[x] Tools", + "[ ] School Leadership", + "- Tool/Artifact", + "- Template" + ] + + # print("Testing checkbox parsing:") + + # Test the parse_list_content function + def test_parse_list_content(content_lines): + items = [] + + for line in content_lines: + line = line.strip() + if not line: + continue + + # Enhanced parsing logic + + # Format 1: Checkbox with bullet: - [x] item + checkbox_with_bullet = re.match(r'^\s*[\-\*•]\s*\[[x\s]\]\s*(.+)', line, re.IGNORECASE) + if checkbox_with_bullet: + item = checkbox_with_bullet.group(1).strip() + items.append(item) + continue + + # Format 2: Direct checkbox: [x] item + direct_checkbox = re.match(r'^\s*\[[x\s]\]\s*(.+)', line, re.IGNORECASE) + if direct_checkbox: + item = direct_checkbox.group(1).strip() + items.append(item) + continue + + # Format 3: Simple bullet: - item + bullet_match = re.match(r'^\s*[\-\*•]\s*(.+)', line) + if bullet_match: + item = bullet_match.group(1).strip() + # Remove any remaining checkbox markers + clean_item = re.sub(r'\[[x\s]\]\s*', '', item, flags=re.IGNORECASE).strip() + items.append(clean_item) + continue + + # Plain text + if line: + clean_item = re.sub(r'\[[x\s]\]\s*', '', line, flags=re.IGNORECASE).strip() + items.append(clean_item) + + return items + + result = test_parse_list_content(test_lines) + + +def get_tag_from_doc(file_url): + # Test the parsing logic first + # print("Testing improved parsing logic:") + test_checkbox_parsing() + + # print("\n" + "="*60) + + # Test with your actual document + # print(f"\nExtracting from document: {test_url}") + + # Get clean extraction results + # tags = get_tags_list(test_url) + # print(f"Clean Tags: {tags}") + + classification = get_classification_list(file_url) + print(f"Clean Classification: {classification}") + return classification diff --git a/chatbot/scripts/knowledge_service/extraction/ai_extraction.py b/chatbot/scripts/knowledge_service/extraction/ai_extraction.py new file mode 100644 index 0000000..0efa3b8 --- /dev/null +++ b/chatbot/scripts/knowledge_service/extraction/ai_extraction.py @@ -0,0 +1,2633 @@ +import json +from typing import Dict, List, Any, Set +from pathlib import Path +import PyPDF2 +import docx +import pandas as pd +from jinja2 import Template +import json_repair +import requests +import tempfile +import os +import re +import base64 +import io +import time +import logging +from urllib.parse import urlparse +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import FileTypeChoices + +# Set up logging +logger = logging.getLogger('django') +MAX_DEPTH = 1 + +# Additional imports for enhanced features +try: + import fitz # PyMuPDF for better PDF handling + + HAS_PYMUPDF = True + logger.info("PyMuPDF available for enhanced PDF processing") +except ImportError: + HAS_PYMUPDF = False + logger.warning("PyMuPDF not available. Using PyPDF2 for text extraction only.") + +try: + from PIL import Image + + HAS_PIL = True + logger.info("PIL available for image processing") +except ImportError: + HAS_PIL = False + logger.warning("PIL not available. Image processing will be limited.") + +try: + import pytesseract + from PIL import Image as PILImage + + HAS_OCR = True + logger.info("pytesseract available for OCR processing") +except ImportError: + HAS_OCR = False + logger.warning("pytesseract not available. Scanned document processing will be limited.") + +try: + import pdfplumber + + HAS_PDFPLUMBER = True + logger.info("pdfplumber available for enhanced PDF text extraction") +except ImportError: + HAS_PDFPLUMBER = False + logger.warning("pdfplumber not available. Using PyMuPDF/PyPDF2 fallback.") + +try: + import openpyxl + + HAS_OPENPYXL = True + logger.info("openpyxl available for enhanced Excel processing") +except ImportError: + HAS_OPENPYXL = False + logger.warning("openpyxl not available. Excel hyperlink extraction will be limited.") + + +class DocumentExtractor: + """Extract structured content from documents using AWS Bedrock Llama model with enhanced features + + Usage examples: + # Default configuration (with image extraction) + extractor = DocumentExtractor() + + # Disable image extraction for faster processing + extractor = DocumentExtractor(extract_images=False) + + # Custom configuration + extractor = DocumentExtractor( + extract_images=False, # No image extraction + main_doc_max_chars=15000, # 15k chars for main doc + subdoc_max_chars=3000, # 3k chars for subdocs + excel_max_rows=30, # Only 30 rows from Excel + excel_max_cols=15 # Only 15 columns from Excel + ) + """ + + def __init__( + self, max_depth: int = MAX_DEPTH, max_subdocs: int = 10, enable_ocr: bool = True, + compress_images: bool = True, extract_images: bool = False, main_doc_max_chars: int = 3000, + subdoc_max_chars: int = 500, excel_max_rows: int = 50, excel_max_cols: int = 20, + max_file_size_mb: int = 50 + ): + """Initialize with enhanced features and configurable limits""" + self.max_depth = max_depth + self.max_subdocs = max_subdocs + self.processed_urls: Set[str] = set() + self.url_cache: Dict[str, str] = {} + self.enable_ocr = enable_ocr and HAS_OCR + self.compress_images = compress_images + self.extract_images = extract_images # Control image extraction + + # Configurable text limits + self.main_doc_max_chars = main_doc_max_chars + self.subdoc_max_chars = subdoc_max_chars + self.excel_max_rows = excel_max_rows + self.excel_max_cols = excel_max_cols + + # File validation + self.allowed_extensions = {'.pdf', '.doc', '.docx', '.txt', '.csv', '.xls', '.xlsx'} + self.max_file_size_mb = max_file_size_mb + self.max_file_size_bytes = self.max_file_size_mb * 1024 * 1024 + + def extract_urls_from_text(self, text: str) -> List[str]: + """Extract all URLs from text content with improved regex""" + try: + # Log the full text for debugging + logger.info("=" * 80) + logger.info("EXTRACTING URLs FROM TEXT") + logger.info("=" * 80) + + # First, let's look specifically for patterns like "word: URL" on separate lines + lines = text.split('\n') + manual_urls = [] + + for i, line in enumerate(lines): + line = line.strip() + + # Check if line contains http anywhere + if 'http' in line: + # Extract all URLs from this line + url_pattern = r'https?://[^\s\n\r]+' + found_urls = re.findall(url_pattern, line, re.IGNORECASE) + manual_urls.extend(found_urls) + + # Also check if previous line ends with description and this line is a URL + if i > 0 and line.startswith('http'): + if line not in manual_urls: + manual_urls.append(line) + + # Enhanced URL patterns for more thorough extraction + url_patterns = [ + # Catch ALL URLs starting with http/https + r'https?://[^\s\n\r]+', + # Google specific patterns + r'https://docs\.google\.com/[^/\s]+/d/[A-Za-z0-9_-]+[^\s\n\r]*', + r'https://drive\.google\.com/[^/\s]+/d/[A-Za-z0-9_-]+[^\s\n\r]*', + ] + + urls = [] + + # Add manually found URLs first + urls.extend(manual_urls) + + # Then use regex patterns on the full text + for pattern in url_patterns: + found_urls = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE) + urls.extend(found_urls) + + # Clean and process URLs + processed_urls = [] + for url in urls: + url = url.strip() + # Remove trailing punctuation and special chars + url = re.sub(r'[.,;:!?)\]}>]+$', '', url) + if url.startswith('www.'): + url = 'https://' + url + processed_urls.append(url) + + # Remove duplicates while preserving order + unique_urls = [] + seen = set() + + for url in processed_urls: + # Normalize by removing trailing slashes + normalized = url.rstrip('/') + + # For Google Docs/Sheets, normalize the gid parameter + if 'docs.google.com/spreadsheets' in normalized and '#gid=' in normalized: + base_url = normalized.split('#gid=')[0] + gid_part = '#gid=' + normalized.split('#gid=')[1].split('&')[0].split('/')[0] + normalized = base_url + gid_part + + if normalized not in seen and len(normalized) > 10: + unique_urls.append(url) + seen.add(normalized) + + logger.info("=" * 80) + logger.info(f"EXTRACTED {len(unique_urls)} UNIQUE URLs:") + logger.info("=" * 80) + for i, url in enumerate(unique_urls): + logger.info(f"URL {i + 1}: {url}") + logger.info("=" * 80) + + return unique_urls + + except Exception as e: + logger.error(f"Error extracting URLs: {e}") + return [] + + def is_document_url(self, url: str, depth: int = 0) -> bool: + """Check if URL points to a document - validates against supported formats""" + try: + # Exclude non-document domains/patterns + excluded_domains = [ + 'googleusercontent.com', 'gstatic.com', 'chrome.google.com', + 'googleapis.com', 'youtube.com', 'twitter.com', 'forms.google.com' + ] + + # Check domain exclusions + for domain in excluded_domains: + if domain in url.lower(): + return False + + # Special handling for Google Docs + if any(pattern in url for pattern in [ + 'docs.google.com/document', + 'drive.google.com/file', + 'docs.google.com/spreadsheets', + 'docs.google.com/forms', + 'docs.google.com/presentation' + ]): + return True + + parsed_url = urlparse(url) + path = parsed_url.path.lower() + + if '.' in path: + extension = path.rsplit('.', 1)[-1] + # Use FileTypeChoices to validate + return FileTypeChoices.is_valid_extension(extension) + + return False + + except Exception as e: + logger.error(f"Error checking if URL is document: {e}") + return False + + def convert_google_drive_url(self, url: str) -> str: + """Convert Google URLs to downloadable formats - now includes spreadsheets and forms""" + try: + if 'docs.google.com/document' in url: + if '/d/' in url: + doc_id = url.split('/d/')[1].split('/')[0] + return f"https://docs.google.com/document/d/{doc_id}/export?format=docx" + + elif 'drive.google.com/file' in url: + if '/d/' in url: + file_id = url.split('/d/')[1].split('/')[0] + return f"https://drive.google.com/uc?id={file_id}&export=download" + + elif 'docs.google.com/spreadsheets' in url: + if '/d/' in url: + sheet_id = url.split('/d/')[1].split('/')[0] + # Remove any gid parameter for export + return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=xlsx" + + elif 'docs.google.com/forms' in url: + # Google Forms can't be downloaded as documents + # Return the URL as-is, it will be handled as non-downloadable + logger.info(f"Google Form detected, cannot convert to downloadable format: {url}") + return url + + return url + except Exception as e: + logger.error(f"Error converting Google Drive URL: {e}") + return url + + def _extract_limited_excel_content(self, content_bytes: bytes, max_chars: int = None) -> str: + """Extract limited content from Excel file for LLM processing""" + if max_chars is None: + max_chars = self.subdoc_max_chars + + try: + excel_file = pd.ExcelFile(io.BytesIO(content_bytes)) + sheet_names = excel_file.sheet_names + + logger.info("=" * 80) + logger.info(f"EXCEL FILE CONTAINS {len(sheet_names)} SHEETS:") + for i, sheet_name in enumerate(sheet_names): + logger.info(f" Sheet {i + 1}: '{sheet_name}'") + logger.info("=" * 80) + + if not sheet_names: + return "" + + # Process sheets until we have enough content + all_text_parts = [] + total_chars = 0 + sheets_processed = 0 + + for sheet_idx, sheet_name in enumerate(sheet_names): + if total_chars >= max_chars: + break + + logger.info(f"Processing sheet {sheet_idx + 1}: '{sheet_name}'") + + try: + # Read the sheet + df = pd.read_excel( + excel_file, + sheet_name=sheet_name + ) + + # Skip empty sheets + if df.empty or len(df) == 0: + logger.warning(f"Sheet '{sheet_name}' is empty, moving to next sheet...") + continue + + # Limit rows and columns for processing + display_df = df.head(self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + display_df = display_df.iloc[:, :self.excel_max_cols] + + # Convert to CSV-like format + csv_string = display_df.to_csv(index=False) + + # Add sheet header if we're processing multiple sheets + if sheets_processed > 0: + all_text_parts.append(f"\n\n--- Sheet: '{sheet_name}' ---\n") + + all_text_parts.append(csv_string) + sheets_processed += 1 + + # Update total characters + current_text = '\n'.join(all_text_parts) + total_chars = len(current_text) + + logger.info(f"Sheet '{sheet_name}' added {len(csv_string)} chars (total: {total_chars} chars)") + + # Add truncation note for this sheet if needed + if len(df) > self.excel_max_rows or len(df.columns) > self.excel_max_cols: + all_text_parts.append( + f"\n[Sheet '{sheet_name}': Showing {min(len(df), self.excel_max_rows)} of {len(df)} rows, " + f"{min(len(df.columns), self.excel_max_cols)} of {len(df.columns)} columns]" + ) + + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}': {e}") + continue + + # If no sheets had data + if sheets_processed == 0: + logger.warning("All sheets are empty!") + return "All Excel sheets are empty (no data found)" + + # Join all parts + full_text = '\n'.join(all_text_parts) + original_length = len(full_text) + + logger.info(f"Processed {sheets_processed} sheets with data, extracted {original_length} chars") + + # Log the content + logger.info("=" * 80) + logger.info("EXCEL CONTENT BEING SENT TO LLM:") + logger.info("=" * 80) + logger.info(full_text) + logger.info("=" * 80) + + # Apply final character limit if needed + if len(full_text) > max_chars: + # Try to cut at a row boundary + lines = full_text.split('\n') + truncated_text = [] + current_length = 0 + + for line in lines: + if current_length + len(line) + 1 > max_chars - 50: + break + truncated_text.append(line) + current_length += len(line) + 1 + + full_text = '\n'.join(truncated_text) + "\n[Content truncated]" + logger.info(f"Excel content truncated from {original_length} to {len(full_text)} chars") + + return full_text + + except Exception as e: + logger.error(f"Error extracting Excel content: {e}") + return "" + + def _extract_comprehensive_excel_content_for_urls(self, content_bytes: bytes) -> tuple[str, List[str]]: + """Extract COMPLETE Excel content from ALL sheets and hyperlinks using openpyxl + Returns: (comprehensive_text, extracted_urls) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE EXCEL CONTENT FOR URL EXTRACTION (OPENPYXL)") + logger.info("=" * 80) + + if not HAS_OPENPYXL: + logger.warning("openpyxl not available, falling back to pandas method") + return self._extract_full_excel_content_for_urls(content_bytes), [] + + wb = openpyxl.load_workbook(io.BytesIO(content_bytes), data_only=True) + sheet_names = wb.sheetnames + + logger.info(f"Processing ALL {len(sheet_names)} sheets for content and URL extraction:") + for i, sheet_name in enumerate(sheet_names): + logger.info(f" Sheet {i + 1}: '{sheet_name}'") + + all_text_parts = [] + extracted_urls = [] + total_urls_found = 0 + + # Process ALL sheets without any limits + for sheet_idx, sheet_name in enumerate(sheet_names): + logger.info( + f"Processing sheet {sheet_idx + 1}/{len(sheet_names)}: '{sheet_name}' for content and URLs...") + + try: + sheet = wb[sheet_name] + + # Check if sheet has data + if sheet.max_row == 1 and sheet.max_column == 1 and sheet.cell(1, 1).value is None: + logger.info(f" Sheet '{sheet_name}' is empty, skipping...") + continue + + logger.info(f" Sheet '{sheet_name}': {sheet.max_row} rows x {sheet.max_column} columns") + + # Extract content in multiple formats + sheet_text_parts = [] + sheet_urls = [] + + # Add sheet header + sheet_text_parts.append(f"\n=== SHEET: {sheet_name} ===") + + # Extract column headers (first row) + headers = [] + for col in range(1, sheet.max_column + 1): + cell = sheet.cell(1, col) + header_value = cell.value + if header_value is not None: + headers.append(str(header_value)) + else: + headers.append(f"Unnamed: {col - 1}") + + sheet_text_parts.append("COLUMNS: " + " | ".join(headers)) + + # Process each row + for row_idx in range(1, sheet.max_row + 1): + row_content = [] + row_has_content = False + + for col_idx in range(1, sheet.max_column + 1): + cell = sheet.cell(row_idx, col_idx) + + # Extract hyperlinks + if cell.hyperlink and cell.hyperlink.target: + url = cell.hyperlink.target + if url not in sheet_urls: + sheet_urls.append(url) + extracted_urls.append(url) + + # Extract cell content + cell_value = cell.value + if cell_value is not None: + cell_str = str(cell_value).strip() + if cell_str: + col_name = headers[col_idx - 1] if col_idx - 1 < len(headers) else f"Col{col_idx}" + row_content.append(f"{col_name}: {cell_str}") + row_has_content = True + + if row_has_content: + sheet_text_parts.append(f"ROW {row_idx}: " + " | ".join(row_content)) + + # Also add CSV-like format for compatibility + sheet_text_parts.append("\n--- CSV FORMAT ---") + csv_rows = [] + for row_idx in range(1, sheet.max_row + 1): + csv_row = [] + for col_idx in range(1, sheet.max_column + 1): + cell = sheet.cell(row_idx, col_idx) + cell_value = cell.value + if cell_value is not None: + csv_row.append(str(cell_value)) + else: + csv_row.append("") + csv_rows.append(",".join(f'"{item}"' for item in csv_row)) + + sheet_text_parts.extend(csv_rows) + + # Join all parts for this sheet + sheet_content = '\n'.join(sheet_text_parts) + + # Count URLs in this sheet for logging + sheet_url_count = len(sheet_urls) + total_urls_found += sheet_url_count + + logger.info( + f" Sheet '{sheet_name}' content: {len(sheet_content)} chars, {sheet_url_count} hyperlinks extracted") + + all_text_parts.append(sheet_content) + + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}' with openpyxl: {e}") + continue + + # Join all sheet content + complete_content = '\n\n'.join(all_text_parts) + + logger.info("=" * 80) + logger.info(f"COMPREHENSIVE EXCEL EXTRACTION COMPLETE (OPENPYXL):") + logger.info(f" - Processed {len(sheet_names)} sheets") + logger.info(f" - Total content: {len(complete_content)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_urls)}") + logger.info(f" - Total URLs found: {total_urls_found}") + logger.info("=" * 80) + + # Log extracted URLs + if extracted_urls: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_urls[:10]): # Log first 10 + logger.info(f" URL {i + 1}: {url}") + if len(extracted_urls) > 10: + logger.info(f" ... and {len(extracted_urls) - 10} more URLs") + + # Log sample content + sample_content = complete_content[:2000] if len(complete_content) > 2000 else complete_content + logger.info("SAMPLE OF COMPREHENSIVE EXCEL CONTENT:") + logger.info(sample_content) + if len(complete_content) > 2000: + logger.info(f"... [TRUNCATED - FULL CONTENT IS {len(complete_content)} CHARS] ...") + logger.info("=" * 80) + + return complete_content, extracted_urls + + except Exception as e: + logger.error(f"Error extracting comprehensive Excel content with openpyxl: {e}") + # Fallback to pandas method + return self._extract_full_excel_content_for_urls(content_bytes), [] + + def _extract_full_excel_content_for_urls(self, content_bytes: bytes) -> str: + """Fallback: Extract full Excel content specifically for URL extraction - no limits""" + try: + excel_file = pd.ExcelFile(io.BytesIO(content_bytes)) + sheet_names = excel_file.sheet_names + + all_text_parts = [] + + # Process ALL sheets without limits + for sheet_name in sheet_names: + try: + df = pd.read_excel(excel_file, sheet_name=sheet_name) + if not df.empty: + # Convert entire sheet to string + csv_string = df.to_csv(index=False) + all_text_parts.append(f"\n--- Sheet: '{sheet_name}' ---\n") + all_text_parts.append(csv_string) + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}': {e}") + continue + + return '\n'.join(all_text_parts) + + except Exception as e: + logger.error(f"Error extracting full Excel content: {e}") + return "" + + def _extract_comprehensive_docx_content_for_urls(self, content_bytes: bytes) -> tuple[str, List[str]]: + """Extract comprehensive DOCX content and hyperlinks + Returns: (comprehensive_text, extracted_hyperlinks) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE DOCX CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + + # Extract all text content + text_parts = [] + extracted_hyperlinks = [] + + # Method 1: Extract from all relationships (most reliable) + logger.info("Extracting hyperlinks from document relationships...") + for rel_id, rel in doc.part.rels.items(): + if hasattr(rel, 'target_ref') and rel.target_ref and rel.target_ref.startswith('http'): + if rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found relationship hyperlink: {rel.target_ref}") + + # Method 2: Process paragraphs and extract hyperlinks from runs + logger.info("Processing paragraphs for content and hyperlinks...") + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + + # Extract hyperlinks from paragraph runs + for run in para.runs: + if hasattr(run, '_element'): + # Look for hyperlink elements in the XML + try: + hyperlinks = run._element.xpath('.//w:hyperlink', + namespaces={ + 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) + for hyperlink in hyperlinks: + r_id = hyperlink.get( + '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id') + if r_id and r_id in doc.part.rels: + try: + rel = doc.part.rels[r_id] + if hasattr(rel, + 'target_ref') and rel.target_ref and rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found paragraph hyperlink: {rel.target_ref}") + except: + continue + except Exception as e: + logger.debug(f"Error extracting hyperlinks from run: {e}") + continue + + # Method 3: Process tables + logger.info("Processing tables for content and hyperlinks...") + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text.strip(): + text_parts.append(f"[Table Cell]: {cell.text}") + + # Extract hyperlinks from table cells + for para in cell.paragraphs: + for run in para.runs: + if hasattr(run, '_element'): + try: + hyperlinks = run._element.xpath('.//w:hyperlink', + namespaces={ + 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) + for hyperlink in hyperlinks: + r_id = hyperlink.get( + '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id') + if r_id and r_id in doc.part.rels: + try: + rel = doc.part.rels[r_id] + if hasattr(rel, + 'target_ref') and rel.target_ref and rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found table hyperlink: {rel.target_ref}") + except: + continue + except Exception as e: + logger.debug(f"Error extracting hyperlinks from table cell: {e}") + continue + + comprehensive_text = '\n'.join(text_parts) + + logger.info(f"DOCX extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + + if extracted_hyperlinks: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_hyperlinks[:10]): + logger.info(f" URL {i + 1}: {url}") + if len(extracted_hyperlinks) > 10: + logger.info(f" ... and {len(extracted_hyperlinks) - 10} more URLs") + + return comprehensive_text, extracted_hyperlinks + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error extracting comprehensive DOCX content: {e}") + # Fallback to basic text extraction + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts), [] + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + except: + return "", [] + + def _extract_comprehensive_pdf_content_for_urls(self, content_bytes: bytes) -> tuple[str, List[str]]: + """Extract comprehensive PDF content and hyperlinks using PyMuPDF + Returns: (comprehensive_text, extracted_hyperlinks) + """ + try: + if not HAS_PYMUPDF: + # Fallback to basic text extraction + text = self._extract_pdf_text_enhanced(content_bytes) + return text, [] + + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE PDF CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + text_parts = [] + extracted_hyperlinks = [] + + logger.info(f"Processing {len(doc)} pages for content and hyperlinks...") + + for page_num in range(len(doc)): + page = doc.load_page(page_num) + + # Extract text + page_text = page.get_text() + if page_text.strip(): + text_parts.append(f"[Page {page_num + 1}]\n{page_text}") + + # Extract links/annotations + links = page.get_links() + page_hyperlinks = [] + + for link in links: + if 'uri' in link and link['uri']: + url = link['uri'] + if url.startswith('http') and url not in extracted_hyperlinks: + extracted_hyperlinks.append(url) + page_hyperlinks.append(url) + + if page_hyperlinks: + logger.info(f" Page {page_num + 1}: {len(page_hyperlinks)} hyperlinks found") + for url in page_hyperlinks: + logger.info(f" - {url}") + + doc.close() + + comprehensive_text = '\n'.join(text_parts) + + logger.info(f"PDF extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + + if extracted_hyperlinks: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_hyperlinks[:10]): + logger.info(f" URL {i + 1}: {url}") + if len(extracted_hyperlinks) > 10: + logger.info(f" ... and {len(extracted_hyperlinks) - 10} more URLs") + + return comprehensive_text, extracted_hyperlinks + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error extracting comprehensive PDF content: {e}") + # Fallback to basic text extraction + text = self._extract_pdf_text_enhanced(content_bytes) + return text, [] + + def _extract_comprehensive_csv_content_for_urls(self, content_bytes: bytes) -> tuple[str, List[str]]: + """Extract comprehensive CSV content (CSV files don't have hyperlinks, but we maintain consistency) + Returns: (comprehensive_text, extracted_hyperlinks) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE CSV CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + # For CSV, there are no embedded hyperlinks, so just extract all text + df_full = pd.read_csv(io.BytesIO(content_bytes)) + comprehensive_text = df_full.to_csv(index=False) + + logger.info(f"CSV extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Rows: {len(df_full)}, Columns: {len(df_full.columns)}") + logger.info(" - No hyperlinks (CSV format doesn't support embedded links)") + + return comprehensive_text, [] + + except Exception as e: + logger.error(f"Error extracting comprehensive CSV content: {e}") + return "", [] + + def _extract_comprehensive_txt_content_for_urls(self, content_bytes: bytes) -> tuple[str, List[str]]: + """Extract comprehensive TXT content (TXT files don't have hyperlinks, but we maintain consistency) + Returns: (comprehensive_text, extracted_hyperlinks) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE TXT CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + # For TXT, there are no embedded hyperlinks, so just extract all text + try: + comprehensive_text = content_bytes.decode('utf-8', errors='ignore') + except: + comprehensive_text = str(content_bytes, errors='ignore') + + logger.info(f"TXT extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(" - No hyperlinks (TXT format doesn't support embedded links)") + + return comprehensive_text, [] + + except Exception as e: + logger.error(f"Error extracting comprehensive TXT content: {e}") + return "", [] + + def _image_to_base64(self, image_bytes: bytes, image_format: str = "PNG") -> str: + """Convert image bytes to base64 string""" + try: + if HAS_PIL and self.compress_images: + # Use PIL to potentially optimize/convert image + image = Image.open(io.BytesIO(image_bytes)) + buffer = io.BytesIO() + + # Convert to RGB if necessary + if image.mode in ('RGBA', 'LA'): + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = background + + # Resize if too large + max_dimension = 1024 + if max(image.size) > max_dimension: + image.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS) + + image.save(buffer, format="JPEG", quality=85, optimize=True) + image_bytes = buffer.getvalue() + + # Encode to base64 + base64_string = base64.b64encode(image_bytes).decode('utf-8') + mime_type = f"image/{image_format.lower()}" + return f"data:{mime_type};base64,{base64_string}" + + except Exception as e: + logger.error(f"Error converting image to base64: {e}") + return "" + + def _extract_images_from_pdf_pymupdf(self, content_bytes: bytes) -> List[Dict[str, Any]]: + """Extract images from PDF using PyMuPDF""" + images = [] + + # Check if image extraction is enabled + if not self.extract_images: + return images + + if not HAS_PYMUPDF: + return images + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + + for page_num in range(len(doc)): + page = doc.load_page(page_num) + image_list = page.get_images() + + max_images_per_page = 10 + + for img_index, img in enumerate(image_list[:max_images_per_page]): + try: + xref = img[0] + pix = fitz.Pixmap(doc, xref) + + # Skip very small images + if pix.width < 100 or pix.height < 100: + pix = None + continue + + # Skip very large images + if pix.width * pix.height > 2048 * 2048: + pix = None + continue + + # Convert to PNG bytes + if pix.n - pix.alpha < 4: # GRAY or RGB + img_bytes = pix.tobytes("png") + base64_image = self._image_to_base64(img_bytes, "PNG") + + if base64_image: + images.append({ + "page": page_num + 1, + "index": img_index, + "width": pix.width, + "height": pix.height, + "base64": base64_image, + "format": "png" + }) + + pix = None + + except Exception as e: + logger.error(f"Error extracting image {img_index} from page {page_num + 1}: {e}") + + if len(images) > 50: + logger.warning("Reached maximum image limit (50)") + break + + doc.close() + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + logger.info(f"Extracted {len(images)} images from PDF") + return images + + except Exception as e: + logger.error(f"Error extracting images from PDF: {e}") + return [] + + def _extract_images_from_docx(self, content_bytes: bytes) -> List[Dict[str, Any]]: + """Extract images from DOCX file""" + images = [] + + # Check if image extraction is enabled + if not self.extract_images: + return images + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + + image_count = 0 + for rel in doc.part.rels.values(): + if "image" in rel.target_ref: + try: + image_part = rel.target_part + image_bytes = image_part.blob + + if len(image_bytes) < 1000: + continue + + content_type = image_part.content_type + image_format = "PNG" + if "jpeg" in content_type or "jpg" in content_type: + image_format = "JPEG" + + base64_image = self._image_to_base64(image_bytes, image_format) + + if base64_image: + images.append({ + "index": image_count, + "base64": base64_image, + "format": image_format.lower() + }) + image_count += 1 + + except Exception as e: + logger.error(f"Error extracting image from DOCX: {e}") + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + logger.info(f"Extracted {len(images)} images from DOCX") + return images + + except Exception as e: + logger.error(f"Error extracting images from DOCX: {e}") + return [] + + def _perform_ocr_on_pdf(self, content_bytes: bytes) -> str: + """Perform OCR on scanned PDF pages""" + if not self.enable_ocr or not HAS_PYMUPDF: + return "" + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + ocr_text_parts = [] + + for page_num in range(min(10, len(doc))): + page = doc.load_page(page_num) + + # Check if page has extractable text + page_text = page.get_text() + if len(page_text.strip()) > 50: + continue + + # Convert page to image for OCR + pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) + img_data = pix.tobytes("png") + + # Perform OCR + image = PILImage.open(io.BytesIO(img_data)) + ocr_text = pytesseract.image_to_string(image, lang='eng') + + if ocr_text.strip(): + ocr_text_parts.append(f"[Page {page_num + 1} - OCR]\n{ocr_text}") + + pix = None + + doc.close() + return '\n\n'.join(ocr_text_parts) + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error performing OCR: {e}") + return "" + + def _extract_pdf_text_enhanced(self, content_bytes: bytes) -> str: + """Enhanced PDF text extraction with multiple methods""" + text = "" + + # Try pdfplumber first if available + if HAS_PDFPLUMBER: + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + text_parts = [] + import pdfplumber + with pdfplumber.open(temp_file_path) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text and page_text.strip(): + text_parts.append(page_text) + text = '\n'.join(text_parts) + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + if text and len(text.strip()) > 50: + logger.info(f"pdfplumber extracted {len(text)} characters") + return text + except Exception as e: + logger.error(f"pdfplumber failed: {e}") + + # Try PyMuPDF next + if HAS_PYMUPDF and not text: + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + text_parts = [] + for page_num in range(len(doc)): + page = doc.load_page(page_num) + text_parts.append(page.get_text()) + text = '\n'.join(text_parts) + doc.close() + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + if text and len(text.strip()) > 50: + logger.info(f"PyMuPDF extracted {len(text)} characters") + return text + except Exception as e: + logger.error(f"PyMuPDF failed: {e}") + + # Fallback to PyPDF2 + if not text: + try: + pdf_reader = PyPDF2.PdfReader(io.BytesIO(content_bytes)) + text_parts = [] + for page in pdf_reader.pages: + text_parts.append(page.extract_text()) + text = '\n'.join(text_parts) + logger.info(f"PyPDF2 extracted {len(text)} characters") + except Exception as e: + logger.error(f"PyPDF2 failed: {e}") + + # Try OCR if text extraction failed + if (not text or len(text.strip()) < 50) and self.enable_ocr: + logger.info("Attempting OCR on potentially scanned document") + ocr_text = self._perform_ocr_on_pdf(content_bytes) + if ocr_text: + text = text + "\n\n[OCR Content]\n" + ocr_text if text else ocr_text + + return text + + def extract_text_from_url(self, url: str, is_subdoc: bool = False) -> tuple[ + str, List[Dict[str, Any]], Any, Dict[str, Any], str]: + """Extract text content and images from document URL, with error handling + Returns: (text, images, media_type, error_info, full_text_for_url_extraction) + """ + error_info = None + max_chars = self.subdoc_max_chars if is_subdoc else self.main_doc_max_chars + + try: + logger.info(f"Extracting from: {url} (subdoc: {is_subdoc}, max_chars: {max_chars})") + + # Check cache + if url in self.url_cache: + cached_result = self.url_cache[url] + if isinstance(cached_result, dict) and 'error' in cached_result: + return "", [], None, cached_result, "" + return cached_result, [], None, None, cached_result + + # Convert Google Drive URLs to downloadable format + download_url = self.convert_google_drive_url(url) + if download_url is None: + logger.info(f"Skipped non-document URL: {url}") + return "", [], None, None, "" + if download_url != url: + logger.info(f"Converted to: {download_url}") + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1' + } + + response = None + max_retries = 2 + retry_count = 0 + + while retry_count < max_retries: + try: + response = requests.get(download_url, headers=headers, timeout=60, allow_redirects=True) + response.raise_for_status() + if response and response.content: + content_size = len(response.content) + if content_size > self.max_file_size_bytes: + content_size_mb = content_size / (1024 * 1024) + error_info = { + 'error': f'File size ({content_size_mb:.2f} MB) exceeds the maximum allowed ' + f'size of {self.max_file_size_mb} MB. Please reduce the file size.', + 'error_type': 'file_size_exceeded', + 'url': url + } + logger.error(f"File too large from URL {url}: {content_size_mb:.2f} MB") + self.url_cache[url] = error_info + return "", [], None, error_info, "" + break + except requests.exceptions.HTTPError as e: + if e.response.status_code == 500 and retry_count < max_retries - 1: + logger.warning(f"500 error, retrying... (attempt {retry_count + 1})") + retry_count += 1 + time.sleep(2) # Wait before retry + + # Try alternative URL format for Google Drive + if 'drive.google.com' in download_url and '/d/' in url: + file_id = url.split('/d/')[1].split('/')[0] + # Try alternative format + download_url = f"https://drive.google.com/uc?export=download&id={file_id}" + logger.info(f"Trying alternative URL format: {download_url}") + else: + raise + except requests.exceptions.Timeout: + if retry_count < max_retries - 1: + logger.warning(f"Timeout, retrying... (attempt {retry_count + 1})") + retry_count += 1 + time.sleep(2) + else: + raise + + if not response: + raise Exception("Failed to get response after retries") + + # Get content type + content_type = response.headers.get('content-type', '').lower() + logger.info(f"Response content_type: {content_type}") + + # Enhanced Google Drive permission detection + if 'drive.google.com' in download_url or 'docs.google.com' in download_url: + # Check if response is HTML-like + if 'html' in content_type or response.text.strip().startswith( + ' self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + text = df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + if len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated]" + media_type = FileTypeChoices.CSV + except Exception as e: + logger.error(f"Error processing CSV: {e}") + text = response.text[:max_chars] + media_type = FileTypeChoices.CSV + + elif is_docx: + # For DOCX, extract comprehensive content and hyperlinks + logger.info("DOCX file detected - extracting comprehensive content for URL detection...") + full_text_for_url_extraction, extracted_hyperlinks = self._extract_comprehensive_docx_content_for_urls( + response.content) + + # Extract limited content for LLM processing + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(response.content) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + text = '\n'.join(text_parts) + media_type = FileTypeChoices.DOCX + logger.info(f"Assigned media type as: {media_type}") + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + images = self._extract_images_from_docx(response.content) + logger.info(f"DOCX processing complete:") + logger.info(f" - Limited content for LLM: {len(text)} chars") + logger.info(f" - Comprehensive content for URLs: {len(full_text_for_url_extraction)} chars") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + + else: + # For TXT and other formats + logger.info("TXT/Other file detected - extracting content...") + full_text_for_url_extraction, extracted_hyperlinks = self._extract_comprehensive_txt_content_for_urls( + response.content) + text = response.text + media_type = FileTypeChoices.TXT + logger.info(f"Assigned media type as: {media_type}") + + # *** CRITICAL: Combine text-based URLs with hyperlink URLs for ALL formats *** + combined_urls = [] + # First add hyperlink URLs (more reliable) + combined_urls.extend(extracted_hyperlinks) + # Then add any URLs found in text content + text_urls = self.extract_urls_from_text(full_text_for_url_extraction) + for url_found in text_urls: + if url_found not in combined_urls: + combined_urls.append(url_found) + + # Store combined URLs for later use by appending hyperlinks to full text + if extracted_hyperlinks: + full_text_for_url_extraction = full_text_for_url_extraction + "\n\n=== EXTRACTED HYPERLINKS ===\n" + "\n".join( + extracted_hyperlinks) + + logger.info(f"URL extraction summary for {url}:") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + logger.info(f" - Text-based URLs found: {len(text_urls)}") + logger.info(f" - Total unique URLs for processing: {len(combined_urls)}") + + # Apply character limit for subdocuments AFTER storing full text + if is_subdoc and len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated]" + + # Final validation - check if we got meaningful content + if not text or len(text.strip()) < 10: + logger.warning(f"No meaningful content extracted from {url}") + error_info = { + 'error': f'No content could be extracted from {url}', + 'error_type': 'no_content', + 'url': url + } + self.url_cache[url] = error_info + return "", [], None, error_info, "" + + if text: + self.url_cache[url] = text + + logger.info( + f"For url: {url}, Extracted {len(text)} characters and {len(images)} " + f"images and file type as {media_type}" + ) + + return text, images, media_type, None, full_text_for_url_extraction + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 403: + error_info = { + 'error': f'Permission denied accessing {url}', + 'error_type': 'permission_denied', + 'status_code': 403, + 'url': url + } + elif e.response.status_code == 404: + error_info = { + 'error': f'Document not found at {url}', + 'error_type': 'not_found', + 'status_code': 404, + 'url': url + } + else: + error_info = { + 'error': f'HTTP error {e.response.status_code} accessing {url}', + 'error_type': 'http_error', + 'status_code': e.response.status_code, + 'url': url + } + logger.error(f"HTTP error extracting from URL {url}: {e}") + self.url_cache[url] = error_info + return "", [], None, error_info, "" + + except requests.exceptions.Timeout: + error_info = { + 'error': f'Timeout accessing {url}', + 'error_type': 'timeout', + 'url': url + } + logger.error(f"Timeout extracting from URL {url}") + self.url_cache[url] = error_info + return "", [], None, error_info, "" + + except Exception as e: + error_info = { + 'error': f'Failed to extract from {url}: {str(e)}', + 'error_type': 'extraction_error', + 'url': url + } + logger.error(f"Failed to extract from URL {url}: {e}") + self.url_cache[url] = error_info + return "", [], None, error_info, "" + + def extract_text_from_file(self, file, file_extension: str) -> tuple[str, List[Dict[str, Any]], str]: + """Extract text content and images from various file types + Returns: (limited_text_for_llm, images, comprehensive_text_for_urls) + """ + try: + file_extension = file_extension.lower().strip('.') + text = "" + images = [] + comprehensive_text_for_urls = "" + + # Handle file path vs file object + if isinstance(file, (str, Path)): + file_path = Path(file) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + if file_extension == 'pdf': + with open(file_path, 'rb') as f: + content_bytes = f.read() + text = self._extract_pdf_text_enhanced(content_bytes) + comprehensive_text_for_urls, _ = self._extract_comprehensive_pdf_content_for_urls(content_bytes) + images = self._extract_images_from_pdf_pymupdf(content_bytes) + elif file_extension in ['doc', 'docx']: + with open(file_path, 'rb') as f: + content_bytes = f.read() + text = self._extract_docx_text(file_path) + comprehensive_text_for_urls, _ = self._extract_comprehensive_docx_content_for_urls(content_bytes) + images = self._extract_images_from_docx(content_bytes) + elif file_extension == 'txt': + with open(file_path, 'rb') as f: + content_bytes = f.read() + text = self._extract_txt_text(file_path) + comprehensive_text_for_urls, _ = self._extract_comprehensive_txt_content_for_urls(content_bytes) + elif file_extension == 'csv': + with open(file_path, 'rb') as f: + content_bytes = f.read() + text = self._extract_csv_text(file_path) + comprehensive_text_for_urls, _ = self._extract_comprehensive_csv_content_for_urls(content_bytes) + elif file_extension in ['xls', 'xlsx']: + with open(file_path, 'rb') as f: + content_bytes = f.read() + text = self._extract_excel_text(file_path) + comprehensive_text_for_urls, extracted_hyperlinks = self._extract_comprehensive_excel_content_for_urls( + content_bytes) + # Combine with hyperlinks + if extracted_hyperlinks: + comprehensive_text_for_urls = comprehensive_text_for_urls + "\n\n=== EXTRACTED HYPERLINKS ===\n" + "\n".join( + extracted_hyperlinks) + logger.info( + f"Main Excel file - Limited content: {len(text)} chars, Comprehensive: {len(comprehensive_text_for_urls)} chars, Hyperlinks: {len(extracted_hyperlinks)}") + else: + # Default case + comprehensive_text_for_urls = text + else: + # Handle file object + if file_extension == 'pdf': + file.seek(0) + content_bytes = file.read() + text = self._extract_pdf_text_enhanced(content_bytes) + comprehensive_text_for_urls, _ = self._extract_comprehensive_pdf_content_for_urls(content_bytes) + images = self._extract_images_from_pdf_pymupdf(content_bytes) + elif file_extension in ['doc', 'docx']: + file.seek(0) + content_bytes = file.read() + text = self._extract_docx_text_from_object(io.BytesIO(content_bytes)) + comprehensive_text_for_urls, _ = self._extract_comprehensive_docx_content_for_urls(content_bytes) + images = self._extract_images_from_docx(content_bytes) + elif file_extension == 'txt': + file.seek(0) + content_bytes = file.read() + text = self._extract_txt_text_from_object(file) + comprehensive_text_for_urls, _ = self._extract_comprehensive_txt_content_for_urls( + content_bytes if isinstance(content_bytes, bytes) else content_bytes.encode('utf-8')) + elif file_extension == 'csv': + file.seek(0) + content_bytes = file.read() + text = self._extract_csv_text_from_object(file) + comprehensive_text_for_urls, _ = self._extract_comprehensive_csv_content_for_urls( + content_bytes if isinstance(content_bytes, bytes) else content_bytes.encode('utf-8')) + elif file_extension in ['xls', 'xlsx']: + file.seek(0) + content_bytes = file.read() + text = self._extract_excel_text_from_object(file) + comprehensive_text_for_urls, extracted_hyperlinks = self._extract_comprehensive_excel_content_for_urls( + content_bytes) + # Combine with hyperlinks + if extracted_hyperlinks: + comprehensive_text_for_urls = comprehensive_text_for_urls + "\n\n=== EXTRACTED HYPERLINKS ===\n" + "\n".join( + extracted_hyperlinks) + logger.info( + f"Main Excel file - Limited content: {len(text)} chars, Comprehensive: {len(comprehensive_text_for_urls)} chars, Hyperlinks: {len(extracted_hyperlinks)}") + else: + # Default case + comprehensive_text_for_urls = text + + return text, images, comprehensive_text_for_urls + + except Exception as e: + logger.error(f"Error extracting from file: {e}") + return "", [], "" + + def _extract_pdf_text(self, file) -> str: + """Extract text from PDF (fallback method)""" + pdf_reader = PyPDF2.PdfReader(file) + text_parts = [] + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + text_parts.append(page.extract_text()) + return '\n'.join(text_parts) + + def _extract_docx_text(self, file_path) -> str: + """Extract text from Word document (file path)""" + doc = docx.Document(file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts) + + def _extract_docx_text_from_object(self, file) -> str: + """Extract text from Word document (file object)""" + doc = docx.Document(file) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts) + + def _extract_txt_text(self, file_path) -> str: + """Extract text from plain text file (file path)""" + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + + def _extract_txt_text_from_object(self, file) -> str: + """Extract text from plain text file (file object)""" + content = file.read() + if isinstance(content, bytes): + content = content.decode('utf-8', errors='ignore') + return content + + def _extract_csv_text(self, file_path) -> str: + """Extract text from CSV (file path) with limits""" + df = pd.read_csv(file_path, nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + return df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + + def _extract_csv_text_from_object(self, file) -> str: + """Extract text from CSV (file object) with limits""" + df = pd.read_csv(file, nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + return df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + + def _extract_excel_text(self, file_path) -> str: + """Extract text from Excel (file path) - first sheet only with limits""" + excel_file = pd.ExcelFile(file_path) + sheet_names = excel_file.sheet_names + + if not sheet_names: + return "" + + # Only read first sheet + df = pd.read_excel(excel_file, sheet_name=sheet_names[0], nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + + text = f"Excel file with {len(sheet_names)} sheets. Processing first sheet: '{sheet_names[0]}'\n" + text += df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + return text + + def _extract_excel_text_from_object(self, file) -> str: + """Extract text from Excel (file object) - first sheet only with limits""" + excel_file = pd.ExcelFile(file) + sheet_names = excel_file.sheet_names + + if not sheet_names: + return "" + + # Only read first sheet + df = pd.read_excel(excel_file, sheet_name=sheet_names[0], nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + + text = f"Excel file with {len(sheet_names)} sheets. Processing first sheet: '{sheet_names[0]}'\n" + text += df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + return text + + def read_document(self, file_path: str) -> str: + """Extract text content from various document formats""" + file_path = Path(file_path) + file_ext = file_path.suffix.lower().strip('.') + + try: + text, _, _ = self.extract_text_from_file(file_path, file_ext) + return text + except Exception as e: + raise Exception(f"Error reading document: {str(e)}") + + def _find_explicit_tag_sections(self, document_text: str) -> List[str]: + """Find explicit tag/classification sections in the document""" + try: + tag_sections = [] + + # Common patterns for explicit tag/classification sections + tag_patterns = [ + r'(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):\s*([^\n\r]+)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n([^\n\r]+(?:\n[^\n\r]+)*?)(?=\n\n|\n[A-Z]|\n\s*$|$)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n((?:\s*[-•*]\s*[^\n\r]+\n?)+)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n((?:\s*\d+\.\s*[^\n\r]+\n?)+)', + ] + + doc_lower = document_text.lower() + + for pattern in tag_patterns: + matches = re.finditer(pattern, doc_lower, re.MULTILINE | re.IGNORECASE) + for match in matches: + section_content = match.group(1).strip() + if section_content and len(section_content) > 2: + tag_sections.append(section_content) + + # Remove duplicates while preserving order + unique_sections = [] + seen_content = set() + for section in tag_sections: + section_key = section.lower().strip() + if section_key not in seen_content: + unique_sections.append(section) + seen_content.add(section_key) + + return unique_sections + + except Exception as e: + logger.error(f"Error finding explicit tag sections: {e}") + return [] + + def _validate_and_enhance_result(self, result: Dict[str, Any], document_text: str) -> Dict[str, Any]: + """Validate and enhance the extracted result""" + # Ensure all required fields exist + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + + for key in default_response: + if key not in result: + result[key] = default_response[key] + + # Ensure exact_content is preserved + if not result.get('exact_content'): + result['exact_content'] = document_text + + # Find explicit tag sections + explicit_tag_sections = self._find_explicit_tag_sections(document_text) + + # Validate tags + if result.get('tags'): + validated_tags = [] + for tag in result['tags']: + if isinstance(tag, str): + # Try to parse as JSON first + try: + parsed_tag = json_repair.repair_json(tag, return_objects=True) + if isinstance(parsed_tag, dict) and 'text' in parsed_tag: + # Successfully parsed as tag dict + validated_tags.append(parsed_tag) + else: + # Not a valid tag dict, treat as plain string + validated_tags.append({"text": tag, "source": "generated"}) + except: + # Failed to parse as JSON, treat as plain string + validated_tags.append({"text": tag, "source": "generated"}) + elif isinstance(tag, dict) and 'text' in tag: + # Already a proper dict with text field + validated_tags.append(tag) + result['tags'] = validated_tags + # Ensure minimum content quality + if not result.get('title') or len(result['title'].strip()) < 3: + content_words = document_text.split()[:10] + result['title'] = ' '.join(content_words).strip() + '...' if content_words else 'Untitled Document' + + return result + + def extract_basic_content( + self, document_text, company_bot, extracted_images: List[Dict[str, Any]] = None, other_data=None, + is_subdoc=False + ) -> Dict[str, Any]: + """Extract basic content using Bedrock""" + default_response = { + "title": "", + "organization": "", + "tags": [], + # "exact_content": document_text, # Always preserve content + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": extracted_images or [] + } + + try: + logger.info("Processing content with Bedrock...") + logger.info(f"Passing Text to llm: {document_text}") + + # Preserve complete content + complete_content = document_text + + system_prompt = [ + { + 'text': company_bot.context + }, + ] + + tool_context_data = json_repair.repair_json(company_bot.tool_context, return_objects=True) if isinstance( + company_bot.tool_context, str) else company_bot.tool_context + + if isinstance(tool_context_data, list) and len(tool_context_data) > 0: + tool_context_data = tool_context_data[0] + + end_context = company_bot.end_context + + if not end_context: + print("Early return due to no data in end context value.") + logger.error("Early return due to no data in end context value.") + default_response['exact_content'] = complete_content + return default_response + + master_document_types = None + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + other_params = json_repair.repair_json(company_bot.other_params, return_objects=True) if isinstance( + company_bot.other_params, str + ) else company_bot.other_params + master_document_types = other_params.get('master_document_types', []) + except Exception as e: + print(f"Error parsing master_document_types: {e}") + logger.error(f"Error parsing master_document_types: {e}") + default_response['exact_content'] = complete_content + return default_response + + # Create analysis version if text is too long + analysis_text = document_text + max_analysis_chars = self.main_doc_max_chars + + if len(document_text) > max_analysis_chars: + first_part = document_text[:max_analysis_chars // 2] + last_part = document_text[-(max_analysis_chars // 2):] + analysis_text = first_part + f"\n\n[SAMPLE - Full: {len(document_text)} chars]\n\n" + last_part + + # Include image information in context if available + image_context = "" + if extracted_images: + image_context = f"\n\nDocument contains {len(extracted_images)} embedded images." + context_data = { + "document_text": analysis_text, + "extracted_images": extracted_images, + "master_tags": other_data.get('master_tag', None) if other_data else None, + "master_document_types": master_document_types + } + template = Template(end_context) + end_context = template.render(context_data) + logger.info(f"Updated Tag Context: \n {end_context}") + messages = [{ + 'role': 'user', + 'content': [{'text': f"{end_context}"}] + }] + + print("Bedrock: Extraction call started.") + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tool_context_data, + aws_key=os.getenv('SG_REPO_AWS_ACCESS_KEY_ID'), + aws_secret_key=os.getenv('SG_REPO_AWS_SECRET_ACCESS_KEY') + ) + logger.info(f"Bedrock response type: {type(response)}") + logger.info("Bedrock response:\n%s", json.dumps(response, indent=2)) + print(f"Bedrock response type: {type(response)}") + print("--------\n\n") + + # *** SIMPLIFIED: Enhanced response type validation *** + if not isinstance(response, dict): + error_msg = ("AI processing failed - unable to extract structured data from document. " + "Please try uploading the file again.") + logger.error( + f"LLM returned unexpected response type: {type(response)}. Expected dictionary. " + f"Response preview: {str(response)[:200] if response else 'None'}") + + if not is_subdoc: + # For main document, this is a critical error - stop processing + raise ValueError(error_msg) + else: + # For subdocument, handle gracefully + default_response['exact_content'] = complete_content + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response + + # Extract the actual data from response + extracted_data = response.pop("parameters", response.pop("input", response)) + if not extracted_data or not isinstance(extracted_data, dict): + error_msg = "AI processing failed - response structure is invalid. Please try uploading the file again." + logger.error( + f"LLM response missing expected data structure. Response keys: {list(response.keys()) if response else 'None'}") + + if not is_subdoc: + raise ValueError(error_msg) + + default_response['exact_content'] = complete_content + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response + + # Preserve complete content + extracted_data['exact_content'] = complete_content + + # Add images if available + if extracted_images: + extracted_data['images'] = extracted_images + + # Validate and enhance result + result = self._validate_and_enhance_result(extracted_data, complete_content) + + if not result.get('title') or not result['title'].strip(): + error_msg = f"AI failed to extract title for {'subdocument' if is_subdoc else 'main document'}" + logger.error(error_msg) + if is_subdoc: + result['extraction_error'] = error_msg + result['error'] = error_msg + result['error_type'] = 'title_extraction_failed' + result['title_extraction_failed'] = True + else: + # For main document, raise exception to stop processing + raise ValueError(error_msg) + + logger.info("Bedrock extraction successful") + return result + + except ValueError as ve: + # Re-raise ValueError for main document processing failures + logger.error(f"LLM processing validation error: {str(ve)}") + raise + except Exception as e: + error_msg = f"LLM processing failed with unexpected error: {str(e)}" + logger.error(error_msg) + default_response['exact_content'] = document_text + if not is_subdoc: + # For main document, raise the exception to stop processing + raise ValueError(error_msg) + else: + # For subdocument, handle gracefully + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response + + def _normalize_url_for_tracking(self, url: str) -> str: + """Normalize URL for deduplication tracking""" + try: + # Remove trailing slashes + normalized = url.rstrip('/') + + # For Google Docs/Sheets, normalize parameters + if 'docs.google.com' in normalized: + # Extract the document/sheet ID + if '/d/' in normalized: + doc_id = normalized.split('/d/')[1].split('/')[0] + + if 'spreadsheets' in normalized: + # For spreadsheets, ignore gid parameter + base = f"https://docs.google.com/spreadsheets/d/{doc_id}" + elif 'document' in normalized: + base = f"https://docs.google.com/document/d/{doc_id}" + elif 'forms' in normalized: + base = f"https://docs.google.com/forms/d/{doc_id}" + else: + base = normalized.split('?')[0].split('#')[0] + + return base + + # For other URLs, remove query parameters for normalization + return normalized.split('?')[0].split('#')[0] + + except Exception as e: + logger.error(f"Error normalizing URL {url}: {e}") + return url + + def process_document_with_links( + self, text: str, company_bot, comprehensive_text: str = None, processed_urls=None, + depth=0, max_depth=MAX_DEPTH, extracted_images: List[Dict[str, Any]] = None, other_data=None + ) -> Dict[str, Any]: + """Process document and extract links from linked documents with enhanced URL extraction for ALL formats""" + if processed_urls is None: + processed_urls = set() + + try: + # Step 1: Extract basic content from current document using Bedrock + logger.info(f"{' ' * depth}Processing main document with Bedrock...") + main_result = self.extract_basic_content(text, company_bot, extracted_images, other_data) + + # *** CRITICAL FIX: Use comprehensive text for URL extraction *** + url_extraction_text = comprehensive_text if comprehensive_text else text + + # Step 2: Extract URLs from comprehensive document content + logger.info(f"{' ' * depth}Extracting URLs from main document...") + logger.info(f"{' ' * depth} - Using comprehensive content: {len(url_extraction_text)} chars") + logger.info(f"{' ' * depth} - Limited text for LLM: {len(text)} chars") + + urls = self.extract_urls_from_text(url_extraction_text) # ← NOW USING COMPREHENSIVE TEXT + main_result["url"] = urls + + # Log all extracted URLs + logger.info(f"{' ' * depth}Total URLs extracted from main document: {len(urls)}") + + # Step 3: Process links + subdocuments = [] + failed_links = [] + + # Filter for document URLs + document_urls = [url for url in urls if self.is_document_url(url, depth)] + logger.info(f"{' ' * depth}Found {len(document_urls)} document URLs in main document") + + # Process each document URL from the main document + for main_doc_url in document_urls: + # Normalize URL for deduplication + normalized_url = self._normalize_url_for_tracking(main_doc_url) + + if normalized_url in processed_urls: + logger.info(f"{' ' * depth}Skipping already processed URL: {main_doc_url}") + continue + + logger.info(f"{' ' * depth}Processing linked document: {main_doc_url}") + processed_urls.add(normalized_url) + + # Extract content from this linked document + linked_text, linked_images, linked_media_type, error_info, full_text_for_urls = self.extract_text_from_url( + main_doc_url, is_subdoc=True + ) + + if error_info: + # Enhanced error handling for different error types + if error_info.get('error_type') == 'unsupported_format': + error_info['error'] = f"Unsupported file format: {error_info['error']}" + + failed_links.append({ + "file_url": main_doc_url, + "error": error_info, + "source_document": "main" + }) + continue + + if linked_text and len(linked_text.strip()) > 10: + # Check if media type was determined + if linked_media_type is None: + logger.warning(f"Could not determine valid media type for {main_doc_url}") + failed_links.append({ + "file_url": main_doc_url, + "error": { + 'error': 'Could not determine valid file type', + 'error_type': 'unknown_format', + 'url': main_doc_url + }, + "source_document": "main" + }) + continue + + # *** CRITICAL: Extract URLs from the COMPREHENSIVE text for ALL file formats *** + logger.info(f"{' ' * depth}Extracting URLs from linked document: {main_doc_url}") + logger.info(f"{' ' * depth} - Media type: {linked_media_type}") + logger.info(f"{' ' * depth} - Using comprehensive content: {len(full_text_for_urls)} chars") + + # Extract URLs from the comprehensive content (now includes hyperlinks for all formats) + links_in_subdoc = self.extract_urls_from_text(full_text_for_urls) + logger.info(f"{' ' * depth}Found {len(links_in_subdoc)} total links inside {main_doc_url}") + + # Filter for document URLs + subdoc_document_urls = [url for url in links_in_subdoc if self.is_document_url(url, depth)] + logger.info(f"{' ' * depth}Found {len(subdoc_document_urls)} document URLs inside {main_doc_url}") + + # Process each document URL found within the linked document + subdoc_count = 0 + for sub_url in subdoc_document_urls: + if subdoc_count >= self.max_subdocs: + logger.info(f"{' ' * (depth + 1)}Reached max subdocs limit ({self.max_subdocs})") + break + + # Normalize URL for deduplication + normalized_sub_url = self._normalize_url_for_tracking(sub_url) + + if normalized_sub_url in processed_urls: + logger.info(f"{' ' * (depth + 1)}Skipping already processed subdocument URL: {sub_url}") + continue + + logger.info(f"{' ' * (depth + 1)}Processing subdocument: {sub_url}") + processed_urls.add(normalized_sub_url) + subdoc_count += 1 + + # Extract content from subdocument URL + sub_text, sub_images, sub_media_type, sub_error_info, _ = self.extract_text_from_url( + sub_url, is_subdoc=True + ) + logger.info(f"for url: {sub_url}, extracted sub_text is: {sub_text}") + + if sub_error_info: + logger.info(f"{' ' * (depth + 1)}Subdocument failed: {sub_error_info}") + + # Enhance error message for unsupported formats + if sub_error_info.get('error_type') == 'unsupported_format': + sub_error_info[ + 'error'] = f"Unsupported file format in linked document: {sub_error_info['error']}" + + failed_links.append({ + "file_url": sub_url, + "error": sub_error_info, + "source_document": main_doc_url + }) + else: + # Successfully accessed - process subdocument with LLM + if sub_text and len(sub_text.strip()) > 10: + # Get the downloadable URL + downloadable_url = self.convert_google_drive_url(sub_url) + + # Check if media type was determined + if sub_media_type is None: + logger.warning(f"Could not determine valid media type for {sub_url}") + failed_links.append({ + "file_url": sub_url, + "error": { + 'error': 'Could not determine valid file type', + 'error_type': 'unknown_format', + 'url': sub_url + }, + "source_document": main_doc_url + }) + continue + + # Process subdocument content with Bedrock + subdoc_result = self.extract_basic_content( + sub_text, + company_bot, + sub_images, + other_data, + is_subdoc=True + ) + + # Check for any extraction errors in subdocument + if (subdoc_result.get('title_extraction_failed') or + subdoc_result.get('extraction_error') or + subdoc_result.get('error') or + subdoc_result.get('error_type')): + error_message = (subdoc_result.get('error') or + subdoc_result.get('extraction_error') or + 'LLM failed to extract title from subdocument') + + error_type = (subdoc_result.get('error_type') or + 'title_extraction_failed') + + logger.error(f"Subdocument extraction failed for {sub_url}: {error_message}") + failed_links.append({ + "file_url": sub_url, + "error": { + 'error': error_message, + 'error_type': error_type, + 'url': sub_url + }, + "source_document": main_doc_url + }) + continue + + # Create subdocument entry (without "url" field) + subdoc_entry = { + "title": subdoc_result.get( + "title", + f"Document from {Path(urlparse(main_doc_url).path).name or 'linked document'}" + ), + "file_url": downloadable_url, + "media_type": sub_media_type, + "source_document": main_doc_url, + "exact_content": sub_text, + "summary": subdoc_result.get("summary", ""), + "tags": subdoc_result.get("tags", []), + "organization": subdoc_result.get("organization", ""), + "document_type": subdoc_result.get("document_type", ""), + "key_entities": subdoc_result.get("key_entities", []), + "subdocument": [], + "images": sub_images or [] + } + subdocuments.append(subdoc_entry) + else: + logger.warning(f"Subdocument {sub_url} has insufficient content") + failed_links.append({ + "file_url": sub_url, + "error": { + 'error': 'Document has insufficient content (less than 10 characters)', + 'error_type': 'insufficient_content', + 'url': sub_url + }, + "source_document": main_doc_url + }) + else: + logger.warning(f"Linked document {main_doc_url} has insufficient content: {linked_text}") + failed_links.append({ + "file_url": main_doc_url, + "error": { + 'error': 'Document has insufficient content (less than 10 characters)', + 'error_type': 'insufficient_content', + 'url': main_doc_url + }, + "source_document": "main" + }) + + main_result["subdocument"] = subdocuments + main_result["failed_links"] = failed_links + + # Log summary + logger.info(f"{' ' * depth}Processing complete:") + logger.info(f"{' ' * depth} - URLs in main document: {len(urls)}") + logger.info(f"{' ' * depth} - Document URLs in main: {len(document_urls)}") + logger.info(f"{' ' * depth} - Successfully processed subdocuments: {len(subdocuments)}") + logger.info(f"{' ' * depth} - Failed: {len(failed_links)}") + logger.info(f"{' ' * depth} - Total URLs processed: {len(processed_urls)}") + + return main_result + + except ValueError as ve: + logger.error(f"Main document processing failed: {str(ve)}") + raise + except Exception as e: + logger.error(f"Error processing document: {str(e)}") + import traceback + traceback.print_exc() + return { + "title": "", + "organization": "", + "tags": [], + "exact_content": text, + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "failed_links": [], + "images": extracted_images or [] + } + + def _determine_media_type_from_url(self, url: str) -> str: + """Determine media type from URL""" + try: + parsed_url = urlparse(url) + path = parsed_url.path.lower() + + # Extract extension if present + if '.' in path: + extension = path.rsplit('.', 1)[-1] + + # Check if it's a valid extension first + if not FileTypeChoices.is_valid_extension(extension): + logger.warning(f"Invalid extension {extension} in URL {url}") + return None # Return None for invalid extensions + + # Use the existing method instead of hardcoding + mime_type = FileTypeChoices.get_mime_from_extension(extension) + if mime_type: + return mime_type.value + else: + # Extension is valid but not mapped - default to TXT + logger.warning(f"No MIME type mapping for valid extension {extension}") + return FileTypeChoices.TXT.value + + # No extension found - default to TXT + return FileTypeChoices.TXT.value + + except Exception as e: + logger.error(f"Error determining media type from URL {url}: {e}") + return FileTypeChoices.TXT.value + + def _get_comprehensive_content_for_url_extraction(self, document_text: str, other_data=None) -> str: + """Get comprehensive content for URL extraction from the original file""" + try: + # If we have the comprehensive content stored in other_data, use it + if other_data and 'comprehensive_text_for_urls' in other_data: + comprehensive_text = other_data['comprehensive_text_for_urls'] + logger.info(f"Using stored comprehensive content: {len(comprehensive_text)} chars") + return comprehensive_text + + # Fallback to the document_text if no comprehensive content available + logger.info(f"No comprehensive content available, using document text: {len(document_text)} chars") + return document_text + + except Exception as e: + logger.error(f"Error getting comprehensive content: {e}") + return document_text + + def extract_with_bedrock( + self, document_text, company_bot, extracted_images: List[Dict[str, Any]] = None, other_data=None + ) -> Dict[str, Any]: + """Main entry point - processes document with recursive link extraction""" + try: + logger.info("Starting document processing with recursive link extraction...") + + # *** CRITICAL FIX: Get comprehensive content for URL extraction *** + comprehensive_text_for_urls = self._get_comprehensive_content_for_url_extraction( + document_text, other_data + ) + + result = self.process_document_with_links( + text=document_text, # Limited text for LLM + comprehensive_text=comprehensive_text_for_urls, # Full text for URL extraction + company_bot=company_bot, + extracted_images=extracted_images, + other_data=other_data + ) + return result + except ValueError as ve: + # Re-raise ValueError so it can be handled by the calling function + logger.error(f"Document processing validation failed: {str(ve)}") + raise # This allows the error to propagate to get_doc_tags_from_ai() + except Exception as e: + logger.error(f"Document processing failed with unexpected error: {str(e)}") + return { + "title": "", + "organization": "", + "tags": [], + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": extracted_images or [] + } + + def extract_with_llm(self, text: str, company_bot=None, max_chars: int = 6000, + extracted_images: List[Dict[str, Any]] = None, other_data=None) -> Dict[str, Any]: + """Extract structured information using AWS Bedrock Llama""" + # Don't truncate - preserve complete content + return self.extract_with_bedrock( + document_text=text, company_bot=company_bot, extracted_images=extracted_images, other_data=other_data + ) + + def process_document_from_url(self, url: str, company_bot=None) -> Dict[str, Any]: + """Process document directly from URL""" + try: + text_content, extracted_images, extracted_media_type, error_info, _ = self.extract_text_from_url( + url, is_subdoc=False + ) + + if error_info: + return { + "error": error_info['error'], + "error_type": error_info.get('error_type', 'unknown'), + "file_path": url, + "file_name": Path(url).name, + } + + if not text_content or len(text_content.strip()) < 10: + raise ValueError("Document appears to be empty or unreadable") + + extracted_info = self.extract_with_llm(text_content, company_bot, extracted_images=extracted_images) + + result = { + "file_path": url, + "file_name": Path(url).name, + "text_length": len(text_content), + **extracted_info + } + + return result + + except Exception as e: + return { + "error": str(e), + "file_path": url, + "file_name": "Unknown", + } + + def process_document(self, file_path: str, company_bot=None, other_data=None) -> Dict[str, Any]: + """Process document from file path""" + try: + # Read document content with enhanced extraction + text_content, extracted_images, comprehensive_text_for_urls = self.extract_text_from_file(file_path, Path( + file_path).suffix.strip('.')) + + if not text_content or len(text_content.strip()) < 10: + raise ValueError("Document appears to be empty or unreadable") + + # Extract structured information using LLM + extracted_info = self.extract_with_llm( + text=text_content, + company_bot=company_bot, + extracted_images=extracted_images, + other_data=other_data, + ) + + # Add metadata + result = { + "file_path": str(file_path), + "file_name": Path(file_path).name, + "text_length": len(text_content), + **extracted_info + } + + return result + + except Exception as e: + return { + "error": str(e), + "file_path": str(file_path), + "file_name": Path(file_path).name if Path(file_path).exists() else "Unknown", + } + + +# Additional helper functions for file object processing +def extract_tags_from_document_url(url: str, company_bot) -> Dict[str, Any]: + """Extract structured information from document URL""" + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + try: + # Parse other_params from company_bot + extractor_config = {} + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + other_params = json.loads(company_bot.other_params) if isinstance( + company_bot.other_params, str) else company_bot.other_params + + # Extract DocumentExtractor configuration + extractor_config = { + 'max_depth': other_params.get('max_depth', MAX_DEPTH), + 'max_subdocs': other_params.get('max_subdocs', 10), + 'enable_ocr': other_params.get('enable_ocr', True), + 'compress_images': other_params.get('compress_images', True), + 'extract_images': other_params.get('extract_images', False), + 'main_doc_max_chars': other_params.get('main_doc_max_chars', 3000), + 'subdoc_max_chars': other_params.get('subdoc_max_chars', 500), + 'excel_max_rows': other_params.get('excel_max_rows', 50), + 'excel_max_cols': other_params.get('excel_max_cols', 20), + 'max_file_size_mb': other_params.get('max_file_size_mb', 50), + } + except Exception as e: + logger.error(f"Error parsing other_params: {e}") + + extractor = DocumentExtractor(**extractor_config) + result = extractor.process_document_from_url(url, company_bot) + return result + except Exception as e: + logger.error(f"Error extracting tags from URL: {e}") + return default_response + + +def extract_tags_from_document_file(file, company_bot, file_extension, other_data): + """Extract structured information from document file""" + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + try: + extractor_config = {} + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + other_params = json.loads(company_bot.other_params) if isinstance( + company_bot.other_params, str + ) else company_bot.other_params + + # Extract DocumentExtractor configuration + extractor_config = { + 'max_depth': other_params.get('max_depth', MAX_DEPTH), + 'max_subdocs': other_params.get('max_subdocs', 10), + 'enable_ocr': other_params.get('enable_ocr', True), + 'compress_images': other_params.get('compress_images', True), + 'extract_images': other_params.get('extract_images', False), + 'main_doc_max_chars': other_params.get('main_doc_max_chars', 3000), + 'subdoc_max_chars': other_params.get('subdoc_max_chars', 500), + 'excel_max_rows': other_params.get('excel_max_rows', 50), + 'excel_max_cols': other_params.get('excel_max_cols', 20), + 'max_file_size_mb': other_params.get('max_file_size_mb', 50), + } + except Exception as e: + logger.error(f"Error parsing other_params: {e}") + + max_file_size_mb = extractor_config.get('max_file_size_mb', 50) + max_file_size_bytes = max_file_size_mb * 1024 * 1024 + file_size = 0 + if hasattr(file, 'size'): + file_size = file.size + elif hasattr(file, 'seek') and hasattr(file, 'tell'): + current_position = file.tell() + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(current_position) + + if file_size > max_file_size_bytes: + file_size_mb = file_size / (1024 * 1024) + error_msg = (f"File size ({file_size_mb:.2f} MB) exceeds the maximum allowed size " + f"of {max_file_size_mb} MB. Please reduce the file size.") + logger.error(error_msg) + raise ValueError(error_msg) + + extractor = DocumentExtractor(**extractor_config) + + # *** CRITICAL FIX: Extract both limited and comprehensive content *** + document_text, extracted_images, comprehensive_text_for_urls = extractor.extract_text_from_file(file, file_extension) + + if not document_text: + return default_response + + # *** CRITICAL FIX: Pass comprehensive content in other_data *** + if not other_data: + other_data = {} + other_data['comprehensive_text_for_urls'] = comprehensive_text_for_urls + + # Extract information using Bedrock with URL processing + result = extractor.extract_with_llm( + document_text, company_bot, extracted_images=extracted_images, other_data=other_data + ) + + return result + + except ValueError as ve: + error_message = str(ve) + logger.error(f"Processing error: {error_message}") + + # *** FIX: Return error response instead of default_response *** + error_response = { + "error": error_message, + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + + # *** NEW: Distinguish between different types of ValueError *** + if "file size" in error_message.lower() or "exceeds the maximum allowed size" in error_message.lower(): + # File size validation error + error_response["error_type"] = "file_size_exceeded" + elif "llm returned" in error_message.lower() or "unexpected list format" in error_message.lower() or "plain text instead" in error_message.lower(): + # LLM response format error + error_response["error_type"] = "llm_response_format_error" + elif "failed to extract title" in error_message.lower(): + # Title extraction error + error_response["error_type"] = "title_extraction_error" + elif "processing failed with unexpected error" in error_message.lower(): + # LLM processing error + error_response["error_type"] = "llm_processing_error" + else: + # Generic validation error + error_response["error_type"] = "validation_error" + + return error_response # ← NOW RETURNS ERROR INSTEAD OF DEFAULT + + except Exception as e: + # *** NEW: Handle any other unexpected errors *** + error_message = f"Unexpected error during document processing: {str(e)}" + logger.error(error_message) + return { + "error": error_message, + "error_type": "unexpected_error", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + +def get_doc_tags_from_ai(file, company_bot, file_extension, other_data): + """Main entry point for document processing with improved error handling""" + try: + result = extract_tags_from_document_file(file, company_bot, file_extension, other_data) + print("Final result: ", result) + logger.info("Final Extraction Result:\n%s", json.dumps(result, indent=2, ensure_ascii=False)) + return result + except ValueError as ve: + error_message = str(ve) + logger.error(f"Processing error: {error_message}") + + # *** SIMPLIFIED: Common error response for all AI processing failures *** + if any(keyword in error_message.lower() for keyword in [ + "ai processing failed", "unable to extract structured data", + "llm returned", "unexpected", "processing failed" + ]): + return { + "error": "AI processing failed - unable to extract structured data from document. Please try uploading the file again.", + "error_type": "ai_processing_failed", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + elif "file size" in error_message.lower() or "exceeds the maximum allowed size" in error_message.lower(): + # File size validation error + return { + "error": error_message, + "error_type": "file_size_exceeded", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + else: + # Generic validation error + return { + "error": f"Document processing failed: {error_message}", + "error_type": "validation_error", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + except Exception as e: + # *** SIMPLIFIED: Handle any other unexpected errors *** + error_message = "AI processing failed - unable to extract structured data from document. Please try uploading the file again." + logger.error(f"Unexpected error during document processing: {str(e)}") + return { + "error": error_message, + "error_type": "ai_processing_failed", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } diff --git a/chatbot/scripts/knowledge_service/extraction/markdown_extraction.py b/chatbot/scripts/knowledge_service/extraction/markdown_extraction.py new file mode 100644 index 0000000..158e1b2 --- /dev/null +++ b/chatbot/scripts/knowledge_service/extraction/markdown_extraction.py @@ -0,0 +1,177 @@ +import os +import django +from django.db.models import Q + +# project_root = Path(__file__).resolve().parent.parent +# sys.path.insert(0, str(project_root)) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam.settings') +django.setup() + +from django.core.files.base import ContentFile +from chatbot.models import Media, FileTypeChoices +from chatbot.utils.knowledge_service.extractor.markdown_extractor import MarkdownExtractor + + +def get_xlsx_stats(): + """ + Get statistics about XLSX files with and without markdown + """ + xlsx_media = Media.objects.filter(media_type=FileTypeChoices.XLSX) + total_xlsx = xlsx_media.count() + without_md = xlsx_media.filter( + Q(markdown_file__isnull=True) | Q(markdown_file='') + ).count() + + return { + 'total': total_xlsx, + 'without_md': without_md, + 'with_md': total_xlsx - without_md + } + + +def print_stats(stats): + """ + Display statistics in formatted output + """ + print(f"Total XLSX files: {stats['total']}") + print(f"With markdown: {stats['with_md']}") + print(f"Without markdown: {stats['without_md']}") + print("-" * 50) + + +def read_file_content(media): + """ + Read file content from media object + """ + if not media.file: + raise ValueError("No file attached") + + media.file.open('rb') + content_bytes = media.file.read() + media.file.close() + + filename = media.file.name.split('/')[-1] + return content_bytes, filename + + +def generate_markdown_content(content_bytes, filename, extractor): + """ + Generate markdown content using MarkdownExtractor + """ + markdown_content, _ = extractor.extract_comprehensive_content_for_urls( + content_bytes, filename + ) + + if not markdown_content or len(markdown_content.strip()) == 0: + raise ValueError("No markdown content generated") + + return markdown_content + + +def create_markdown_filename(original_filename): + """ + Create markdown filename from original filename + """ + base_filename = os.path.splitext(original_filename)[0] + markdown_filename = f"Markdown_{base_filename}.md" + + if not markdown_filename.endswith('.md'): + markdown_filename = f"{markdown_filename}.md" + + return markdown_filename + + +def save_markdown_to_media(media, markdown_content, markdown_filename): + """ + Save markdown content to media object + """ + markdown_content_bytes = markdown_content.encode('utf-8') + + media.markdown_file.save( + markdown_filename, + ContentFile(markdown_content_bytes), + save=True + ) + + +def process_single_media(media, extractor, index, total): + """ + Process a single media object to generate markdown + """ + print(f"[{index}/{total}] Processing: {media.name} (ID: {media.id})") + + try: + content_bytes, filename = read_file_content(media) + markdown_content = generate_markdown_content(content_bytes, filename, extractor) + markdown_filename = create_markdown_filename(filename) + save_markdown_to_media(media, markdown_content, markdown_filename) + + print(f" ✓ Markdown saved: {markdown_filename}") + return True, None + + except Exception as e: + error_msg = str(e) + print(f" ✗ Error: {error_msg}") + return False, error_msg + + +def generate_markdown_files(): + """ + Main function to generate markdown files for Excel media + """ + stats = get_xlsx_stats() + print_stats(stats) + + if stats['without_md'] == 0: + print("All XLSX files already have markdown files!") + return + + print(f"Starting generation for {stats['without_md']} files...") + print("-" * 50) + + extractor = MarkdownExtractor() + media_without_md = Media.objects.filter( + media_type=FileTypeChoices.XLSX + ).filter( + Q(markdown_file__isnull=True) | Q(markdown_file='') + ) + + success_list = [] + error_list = [] + + for i, media in enumerate(media_without_md, 1): + success, error_msg = process_single_media(media, extractor, i, stats['without_md']) + + if success: + success_list.append({ + 'id': media.id, + 'name': media.name + }) + else: + error_list.append({ + 'id': media.id, + 'name': media.name, + 'error': error_msg + }) + + print("-" * 50) + print(f"Complete! Success: {len(success_list)}, Errors: {len(error_list)}") + print("=" * 50) + + if success_list: + print(f"\n✓ SUCCESSFUL ({len(success_list)}):") + for item in success_list: + print(f" - ID {item['id']}: {item['name']}") + + if error_list: + print(f"\n✗ FAILED ({len(error_list)}):") + for item in error_list: + print(f" - ID {item['id']}: {item['name']}") + print(f" Error: {item['error']}") + + print("=" * 50) + + +# if __name__ == "__main__": +# generate_markdown_files() \ No newline at end of file diff --git a/chatbot/scripts/knowledge_service/file_type_correction.py b/chatbot/scripts/knowledge_service/file_type_correction.py new file mode 100644 index 0000000..0f290c7 --- /dev/null +++ b/chatbot/scripts/knowledge_service/file_type_correction.py @@ -0,0 +1,225 @@ +import os +import django +import sys +from pathlib import Path + +# project_root = Path(__file__).resolve().parent.parent +# sys.path.insert(0, str(project_root)) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam.settings') +django.setup() + +from chatbot.models import Media, FileTypeChoices + + +def get_file_extension(filename): + """ + Extract file extension from filename + """ + if not filename: + return None + + ext = os.path.splitext(filename)[1].lower() + return ext.lstrip('.') + + +def get_media_type_from_extension(extension): + """ + Map file extension to FileTypeChoices + """ + if not extension: + return None + + extension_mapping = { + 'pdf': FileTypeChoices.PDF, + 'doc': FileTypeChoices.DOC, + 'docx': FileTypeChoices.DOCX, + 'txt': FileTypeChoices.TXT, + 'csv': FileTypeChoices.CSV, + 'xls': FileTypeChoices.XLS, + 'xlsx': FileTypeChoices.XLSX, + } + + return extension_mapping.get(extension.lower()) + + +def analyze_media_types(): + """ + Analyze all media to find mismatches between file extension and media_type + """ + all_media = Media.objects.all() + total_count = all_media.count() + + mismatch_list = [] + no_file_list = [] + unknown_extension_list = [] + correct_count = 0 + + print(f"Analyzing {total_count} media objects...") + print("-" * 50) + + for media in all_media: + if not media.file: + no_file_list.append({ + 'id': media.id, + 'name': media.name, + 'stored_type': media.media_type + }) + continue + + filename = media.file.name.split('/')[-1] + file_extension = get_file_extension(filename) + + if not file_extension: + no_file_list.append({ + 'id': media.id, + 'name': media.name, + 'stored_type': media.media_type, + 'filename': filename + }) + continue + + expected_media_type = get_media_type_from_extension(file_extension) + + if not expected_media_type: + unknown_extension_list.append({ + 'id': media.id, + 'name': media.name, + 'extension': file_extension, + 'stored_type': media.media_type + }) + continue + + if media.media_type != expected_media_type: + mismatch_list.append({ + 'id': media.id, + 'name': media.name, + 'filename': filename, + 'file_extension': file_extension, + 'stored_type': media.media_type, + 'expected_type': expected_media_type + }) + else: + correct_count += 1 + + return { + 'total': total_count, + 'correct': correct_count, + 'mismatches': mismatch_list, + 'no_file': no_file_list, + 'unknown_extension': unknown_extension_list + } + + +def print_analysis_results(results): + """ + Display analysis results in formatted output + """ + print(f"Total media objects: {results['total']}") + print(f"Correct media types: {results['correct']}") + print(f"Mismatched media types: {len(results['mismatches'])}") + print(f"No file attached: {len(results['no_file'])}") + print(f"Unknown extensions: {len(results['unknown_extension'])}") + print("=" * 50) + + +def fix_single_media_type(media_info): + """ + Fix media type for a single media object + """ + try: + # media = Media.objects.get(id=media_info['id']) + # old_type = media.media_type + # media.media_type = media_info['expected_type'] + # media.save() + media_id = media_info['id'] + new_type = media_info['expected_type'] + + media = Media.objects.only('id', 'media_type').get(id=media_id) + old_type = media.media_type + + if old_type == new_type: + return True, "No change needed" + + Media.objects.filter(id=media_id).update( + media_type=new_type + ) + + return True, f"Changed from {old_type} to {media_info['expected_type']}" + + except Exception as e: + return False, str(e) + + +def fix_media_types(): + """ + Main function to analyze and fix media type mismatches + """ + results = analyze_media_types() + print_analysis_results(results) + + if len(results['mismatches']) == 0: + print("No mismatches found! All media types are correct.") + return + + print(f"\nStarting correction for {len(results['mismatches'])} mismatched files...") + print("-" * 50) + + success_list = [] + error_list = [] + + for i, media_info in enumerate(results['mismatches'], 1): + print(f"[{i}/{len(results['mismatches'])}] Fixing: {media_info['name']} (ID: {media_info['id']})") + print(f" File: {media_info['filename']} (.{media_info['file_extension']})") + print(f" Stored: {media_info['stored_type']} → Expected: {media_info['expected_type']}") + + success, message = fix_single_media_type(media_info) + + if success: + print(f" ✓ {message}") + success_list.append({ + 'id': media_info['id'], + 'name': media_info['name'], + 'old_type': media_info['stored_type'], + 'new_type': media_info['expected_type'] + }) + else: + print(f" ✗ Error: {message}") + error_list.append({ + 'id': media_info['id'], + 'name': media_info['name'], + 'error': message + }) + + print("-" * 50) + print(f"Complete! Success: {len(success_list)}, Errors: {len(error_list)}") + print("=" * 50) + + if success_list: + print(f"\n✓ SUCCESSFULLY FIXED ({len(success_list)}):") + for item in success_list: + print(f" - ID {item['id']}: {item['name']}") + print(f" Changed: {item['old_type']} → {item['new_type']}") + + if error_list: + print(f"\n✗ FAILED ({len(error_list)}):") + for item in error_list: + print(f" - ID {item['id']}: {item['name']}") + print(f" Error: {item['error']}") + + if results['no_file']: + print(f"\n⚠ NO FILE ATTACHED ({len(results['no_file'])}):") + for item in results['no_file']: + print(f" - ID {item['id']}: {item['name']} (Type: {item['stored_type']})") + + if results['unknown_extension']: + print(f"\n⚠ UNKNOWN EXTENSIONS ({len(results['unknown_extension'])}):") + for item in results['unknown_extension']: + print(f" - ID {item['id']}: {item['name']}") + print(f" Extension: .{item['extension']} (Type: {item['stored_type']})") + + print("=" * 50) + + +# if __name__ == "__main__": +# fix_media_types() diff --git a/chatbot/scripts/knowledge_service/generate_thumbnails.py b/chatbot/scripts/knowledge_service/generate_thumbnails.py new file mode 100644 index 0000000..87666b3 --- /dev/null +++ b/chatbot/scripts/knowledge_service/generate_thumbnails.py @@ -0,0 +1,145 @@ +import os +import sys +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'your_project.settings') +django.setup() + +from chatbot.models import Media +from chatbot.celery_tasks.knowledge_service.media_tasks import generate_media_preview +from django.db.models import Q +import logging + +logger = logging.getLogger('django') + + +def generate_thumbnails_for_media(company_slug=None, limit=None, media_ids=None, force=False): + query = Q() + + if not force: + query &= Q(thumbnail__isnull=True) | Q(thumbnail='') + + if company_slug: + query &= Q(organization__slug=company_slug) + + if media_ids: + query &= Q(id__in=media_ids) + + media_qs = Media.objects.filter(query).order_by('-created_at') + + if limit: + media_qs = media_qs[:limit] + + total_count = media_qs.count() + + if total_count == 0: + print("No media files found matching the criteria.") + return + + print(f"Found {total_count} media file(s) to process.") + print("-" * 60) + + success_count = 0 + error_count = 0 + skipped_count = 0 + + for idx, media in enumerate(media_qs, 1): + try: + print(f"\n[{idx}/{total_count}] Processing Media ID: {media.id}") + print(f" Name: {media.name}") + print(f" Type: {media.media_type}") + print(f" File: {media.file.name if media.file else 'N/A'}") + + if not media.file: + print(" ⚠️ Skipped: No file attached") + skipped_count += 1 + continue + + task = generate_media_preview.apply_async(args=(media.id,), countdown=2) + print(f" ✅ Task queued: {task.id}") + success_count += 1 + + except Exception as e: + print(f" ❌ Error: {str(e)}") + error_count += 1 + logger.error(f"Error processing media {media.id}: {str(e)}") + + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"Total processed: {total_count}") + print(f"✅ Tasks queued: {success_count}") + print(f"⚠️ Skipped: {skipped_count}") + print(f"❌ Errors: {error_count}") + print("=" * 60) + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='Generate thumbnails for Media objects' + ) + parser.add_argument( + '--company-slug', + type=str, + help='Filter by company slug' + ) + parser.add_argument( + '--limit', + type=int, + help='Maximum number of media to process' + ) + parser.add_argument( + '--media-ids', + type=str, + help='Comma-separated list of media IDs to process' + ) + parser.add_argument( + '--force', + action='store_true', + help='Regenerate thumbnails even if they exist' + ) + + args, unknown = parser.parse_known_args() + + media_ids = None + if args.media_ids: + media_ids = [int(x.strip()) for x in args.media_ids.split(',')] + + print("=" * 60) + print("THUMBNAIL GENERATION SCRIPT") + print("=" * 60) + + if args.company_slug: + print(f"Company: {args.company_slug}") + if args.limit: + print(f"Limit: {args.limit}") + if media_ids: + print(f"Media IDs: {media_ids}") + if args.force: + print("Mode: Force regeneration") + + print("=" * 60) + + if not media_ids or len(media_ids) > 10: + response = input("\nProceed with thumbnail generation? (y/n): ") + if response.lower() != 'y': + print("Aborted.") + return + + generate_thumbnails_for_media( + company_slug=args.company_slug, + limit=args.limit, + media_ids=media_ids, + force=args.force + ) + + +def run(*args): + sys.argv = ['generate_thumbnails.py'] + list(args) + main() + +# +# if __name__ == '__main__': +# main() diff --git a/chatbot/scripts/knowledge_service/openai_vector_store/delete_from_store.py b/chatbot/scripts/knowledge_service/openai_vector_store/delete_from_store.py new file mode 100644 index 0000000..84d0819 --- /dev/null +++ b/chatbot/scripts/knowledge_service/openai_vector_store/delete_from_store.py @@ -0,0 +1,83 @@ +import os +import requests +import time +from chatbot.models import CompanyBot + +# Configuration +BOT_ROUTE = "/free-flow-bot" +api_key = os.getenv('OPENAI_API_KEY') + +OPENAI_HEADERS = { + "Authorization": f"Bearer {api_key}", + "OpenAI-Beta": "assistants=v2", +} + +# Get vector store ID +import json_repair + +company_bot = CompanyBot.objects.filter(route=BOT_ROUTE).first() +tool = company_bot.tool_context +if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + +vector_store_id = tool.get("tool")[0].get("vector_store_ids")[0] +print(f"✅ Vector Store ID: {vector_store_id}") + +list_url = f"https://api.openai.com/v1/vector_stores/{vector_store_id}/files" + +total_deleted = 0 +iteration = 0 +max_iterations = 100 # Safety limit + +while iteration < max_iterations: + iteration += 1 + + # Fetch files + response = requests.get(list_url, headers=OPENAI_HEADERS, timeout=60) + response.raise_for_status() + files = response.json().get('data', []) + + if not files: + print(f"\n🎉 Vector store is empty! Total deleted: {total_deleted}") + break + + print(f"\n{'=' * 80}") + print(f"ITERATION {iteration}: Found {len(files)} files") + print(f"{'=' * 80}") + + # Delete all files in this batch + deleted = 0 + failed = 0 + + for i, file in enumerate(files): + file_id = file.get('id') + try: + delete_url = f"https://api.openai.com/v1/vector_stores/{vector_store_id}/files/{file_id}" + delete_response = requests.delete(delete_url, headers=OPENAI_HEADERS, timeout=60) + + if delete_response.ok: + deleted += 1 + total_deleted += 1 + print(f"✅ [{i + 1}/{len(files)}] Deleted: {file_id}") + else: + failed += 1 + print(f"❌ [{i + 1}/{len(files)}] Failed: {file_id} - {delete_response.text}") + + except Exception as e: + failed += 1 + print(f"❌ [{i + 1}/{len(files)}] Error: {file_id} - {str(e)}") + + print(f"\nIteration {iteration} summary: Deleted {deleted}, Failed {failed}") + + # Small delay before next iteration + time.sleep(1) + +if iteration >= max_iterations: + print(f"\n⚠️ Reached max iterations ({max_iterations}). Files might still remain.") +else: + print(f"\n✅ All done! Total iterations: {iteration}, Total deleted: {total_deleted}") + +# Final verification +verify_response = requests.get(list_url, headers=OPENAI_HEADERS, timeout=60) +remaining = len(verify_response.json().get('data', [])) +print(f"🔍 Final count - Files remaining: {remaining}") diff --git a/chatbot/scripts/knowledge_service/openai_vector_store/vector_store_uploader.py b/chatbot/scripts/knowledge_service/openai_vector_store/vector_store_uploader.py new file mode 100644 index 0000000..9d17dc9 --- /dev/null +++ b/chatbot/scripts/knowledge_service/openai_vector_store/vector_store_uploader.py @@ -0,0 +1,516 @@ +""" +Standalone Media to OpenAI Vector Store Uploader +All-in-one script that can be run directly or pasted into terminal +Requirements: + - OPENAI_API_KEY must be set in environment + - OPENAI_VECTOR_STORE_ID must be set in environment + - Django must be properly configured +""" + +import os +import sys +import logging +import requests +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Tuple, Optional, List +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Django setup +import django + +from chatbot.celery_tasks.knowledge_service.media_tasks import prepare_vector_db_data + +try: + project_root = Path(__file__).resolve().parent.parent.parent.parent.parent +except NameError: + # __file__ is not defined in interactive shell, use cwd + project_root = Path.cwd().parent.parent.parent.parent +sys.path.insert(0, str(project_root)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') +django.setup() + +from chatbot.models import Media, CompanyBot + + +# ============================================================================ +# EXCEPTIONS +# ============================================================================ + +class OpenAIVectorStoreError(Exception): + """Base exception for OpenAI vector store operations""" + pass + + +class OpenAIUploadError(OpenAIVectorStoreError): + """Exception raised when uploading file to OpenAI fails""" + pass + + +class VectorStoreError(OpenAIVectorStoreError): + """Exception raised when adding file to vector store fails""" + pass + + +class InvalidMediaError(OpenAIVectorStoreError): + """Exception raised when media object is invalid or missing required data""" + pass + + +# ============================================================================ +# OPENAI CLIENT +# ============================================================================ + +class OpenAIClient: + """Client for interacting with OpenAI API""" + + OPENAI_FILES_URL = "https://api.openai.com/v1/files" + OPENAI_VECTOR_STORE_FILES_URL = "https://api.openai.com/v1/vector_stores/{vector_store_id}/files" + + def __init__(self, api_key: Optional[str] = None, vector_store_id: Optional[str] = None, bot_route: Optional[str] = None): + """Initialize OpenAI client""" + self.api_key = api_key or os.getenv('OPENAI_API_KEY') + + if not self.api_key: + raise ValueError("OPENAI_API_KEY not found in environment variables") + + # Get vector store ID from CompanyBot if bot_route is provided + if bot_route: + self.vector_store_id = self._get_vector_store_id_from_bot(bot_route) + else: + self.vector_store_id = vector_store_id or os.getenv('OPENAI_VECTOR_STORE_ID') + + if not self.vector_store_id: + raise ValueError( + "OPENAI_VECTOR_STORE_ID not found. Either set environment variable or provide bot_route" + ) + + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "OpenAI-Beta": "assistants=v2", + } + + def _get_vector_store_id_from_bot(self, bot_route: str) -> Optional[str]: + """Get vector store ID from CompanyBot's tool_context""" + import json_repair + + try: + company_bot = CompanyBot.objects.filter(route=bot_route).first() + + if not company_bot: + raise ValueError(f"CompanyBot with route '{bot_route}' not found") + + logger.info(f"Found CompanyBot: {company_bot.name} (ID: {company_bot.id}, Route: {bot_route})") + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + vector_store_id = None + if tool and isinstance(tool, dict): + tool_list = tool.get("tool") + if isinstance(tool_list, list) and tool_list: + first_tool = tool_list[0] + if isinstance(first_tool, dict): + vs_ids = first_tool.get("vector_store_ids") + if isinstance(vs_ids, list) and vs_ids: + vector_store_id = vs_ids[0] + + if not vector_store_id: + raise ValueError( + f"Vector store ID not found in tool_context for bot '{bot_route}' (ID: {company_bot.id})" + ) + + logger.info(f"Extracted Vector Store ID: {vector_store_id}") + print(f"✅ Using Vector Store ID from bot '{bot_route}': {vector_store_id}") + + return vector_store_id + + except Exception as e: + logger.error(f"Failed to get vector store ID from bot route '{bot_route}': {str(e)}") + raise + + def add_file_to_vector_store( + self, + file_id: str, + metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """Add uploaded file to vector store with metadata""" + try: + vector_store_url = self.OPENAI_VECTOR_STORE_FILES_URL.format( + vector_store_id=self.vector_store_id + ) + + # Keep only specific fields + ALLOWED_FIELDS = ['company', 'url', 'tags', 'TITLE', 'type', 'DOCUMENT TYPE'] + + attributes = {} + for k, v in metadata.items(): + if k in ALLOWED_FIELDS and v is not None: + # Stringify tags if it's a list + if k == 'tags' and isinstance(v, list): + attributes[str(k)] = ', '.join(str(tag) for tag in v) + else: + attributes[str(k)] = str(v) + + logger.info(f"Metadata: {len(attributes)} fields - {list(attributes.keys())}") + + payload = { + "file_id": file_id, + "attributes": attributes, + } + + response = requests.post( + vector_store_url, + headers={**self.headers, "Content-Type": "application/json"}, + json=payload, + timeout=60 + ) + + if not response.ok: + error_body = response.text + logger.error(f"OpenAI Error ({response.status_code}): {error_body}") + response.raise_for_status() + + return response.json() + + except Exception as e: + raise VectorStoreError( + f"Failed to add file to vector store. File ID: {file_id}. " + f"Error: {str(e)}" + ) + + +# ============================================================================ +# UPLOADER +# ============================================================================ + +try: + SCRIPT_DIR = Path(__file__).parent +except NameError: + SCRIPT_DIR = Path.cwd() +LOG_FILE = SCRIPT_DIR / 'openai_vector_store_upload.log' + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + + +class MediaVectorStoreUploader: + """Orchestrator for uploading all media files to OpenAI Vector Store""" + + def __init__( + self, + max_workers: int = 4, + bot_route: Optional[str] = None, + vector_store_id: Optional[str] = None, + limit: Optional[int] = None, + media_ids: Optional[List[int]] = None + ): + """ + Initialize uploader with OpenAI client + + Args: + max_workers: Number of parallel workers + bot_route: Route of CompanyBot to get vector store ID from (e.g., "/free-flow-bot") + vector_store_id: Direct vector store ID (alternative to bot_route) + limit: Number of media files to process (None = process all) + media_ids: List of specific media IDs to process (None = process all) + """ + self.client = OpenAIClient(bot_route=bot_route, vector_store_id=vector_store_id) + self.max_workers = max_workers + self.limit = limit + self.media_ids = media_ids + self.stats = { + 'total': 0, + 'successful': 0, + 'failed': 0, + 'skipped': 0 + } + self.results = [] + + def _get_media_info(self, media: Media) -> Dict[str, Any]: + """Extract required information from media object using prepare_vector_db_data""" + try: + media_obj, file_name, file_content, metadata = prepare_vector_db_data( + media_id=media.id, + company_slug=None + ) + + organization = metadata.get('company') + + if not organization: + raise InvalidMediaError(f"Media ID {media.id} has no organization in metadata") + + if not file_name: + raise InvalidMediaError(f"Media ID {media.id} has no filename") + + if not file_content: + raise InvalidMediaError(f"Media ID {media.id} has no file content") + + return { + 'organization': organization, + 'file_name': file_name, + 'file_content': file_content, + 'metadata': metadata + } + except Exception as e: + raise InvalidMediaError(f"Failed to extract info from media ID {media.id}: {str(e)}") + + def _upload_single_media(self, media: Media) -> Tuple[int, bool, str, Dict[str, Any]]: + """Upload a single media file to OpenAI vector store""" + try: + # Extract media information + media_info = self._get_media_info(media) + + logger.info( + f"Processing Media ID: {media.id}, Name: {media.name}, " + f"Company: {media_info['organization']}, File: {media_info['file_name']}" + ) + + # Upload file content directly to OpenAI + openai_response = requests.post( + self.client.OPENAI_FILES_URL, + headers=self.client.headers, + files={ + "file": (media_info['file_name'], media_info['file_content']), + }, + data={ + "purpose": "assistants", + }, + ) + + openai_response.raise_for_status() + upload_response = openai_response.json() + file_id = upload_response.get('id') + + if not file_id: + raise OpenAIUploadError(f"No file_id returned from OpenAI for media {media.id}") + + # Add to vector store with metadata + vector_store_response = self.client.add_file_to_vector_store( + file_id=file_id, + metadata=media_info['metadata'] + ) + + result = { + "success": True, + "media_id": media.id, + "media_name": media.name, + "file_id": file_id, + "company": media_info['organization'], + "file_name": media_info['file_name'] + } + + success_msg = ( + f"[SUCCESS] Media ID: {media.id}, Name: {media.name}, " + f"File ID: {file_id}, Company: {media_info['organization']}" + ) + logger.info(success_msg) + + return (media.id, True, 'success', result) + + except InvalidMediaError as e: + skip_msg = ( + f"[SKIPPED] Media ID: {media.id}, Name: {media.name}, " + f"Reason: {str(e)}" + ) + logger.warning(skip_msg) + return (media.id, False, 'skipped', { + "success": False, + "media_id": media.id, + "media_name": media.name, + "error": str(e), + "error_type": "skipped" + }) + + except OpenAIVectorStoreError as e: + error_msg = ( + f"[FAILED] Media ID: {media.id}, Name: {media.name}, " + f"Error: {str(e)}" + ) + logger.error(error_msg) + return (media.id, False, 'failed', { + "success": False, + "media_id": media.id, + "media_name": media.name, + "error": str(e), + "error_type": "failed" + }) + + except Exception as e: + error_msg = ( + f"[FAILED] Media ID: {media.id}, Name: {media.name}, " + f"Unexpected error: {str(e)}" + ) + logger.error(error_msg) + return (media.id, False, 'failed', { + "success": False, + "media_id": media.id, + "media_name": media.name, + "error": str(e), + "error_type": "failed" + }) + + def _upload_media_parallel(self, all_media): + """Upload media files in parallel using ThreadPoolExecutor""" + logger.info(f"Running with ThreadPoolExecutor (workers={self.max_workers})") + print(f"[INFO] Running with ThreadPoolExecutor (workers={self.max_workers})") + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Submit all tasks + futures = [ + executor.submit(self._upload_single_media, media) + for media in all_media + ] + + # Process completed tasks + completed = 0 + for future in as_completed(futures): + completed += 1 + media_id, success, status, result = future.result() + + # Store result + self.results.append(result) + + # Update stats based on status + if status == 'success': + self.stats['successful'] += 1 + elif status == 'skipped': + self.stats['skipped'] += 1 + elif status == 'failed': + self.stats['failed'] += 1 + + # Log progress + if completed % 10 == 0 or completed == self.stats['total']: + logger.info(f"Progress: {completed}/{self.stats['total']} completed") + print(f"[INFO] Progress: {completed}/{self.stats['total']} completed") + + def run(self): + """Main execution method - processes all media files""" + start_time = datetime.now() + + logger.info("=" * 80) + logger.info("Starting OpenAI Vector Store Upload Process") + logger.info(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}") + logger.info(f"Vector Store ID: {self.client.vector_store_id}") + if self.limit: + logger.info(f"Limit: Processing only {self.limit} media file(s)") + if self.media_ids: + logger.info(f"Processing specific media IDs: {self.media_ids}") + logger.info("=" * 80) + + # Query media from database + if self.media_ids: + # Filter by specific media IDs + all_media = Media.objects.filter(id__in=self.media_ids) + print(f"🔍 Processing specific media IDs: {self.media_ids}") + else: + # Get all media + all_media = Media.objects.all() + + # Apply limit if specified (only if not using media_ids) + if self.limit and not self.media_ids: + all_media = all_media[:self.limit] + print(f"⚠️ LIMIT ACTIVE: Processing only {self.limit} media file(s)") + + all_media = list(all_media) + self.stats['total'] = len(all_media) + + logger.info(f"Total media files to process: {self.stats['total']}") + logger.info("-" * 80) + + # Process media files in parallel + self._upload_media_parallel(all_media) + + # Log final summary + end_time = datetime.now() + duration = end_time - start_time + + logger.info("=" * 80) + logger.info("Upload Process Completed") + logger.info(f"End Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}") + logger.info(f"Duration: {duration}") + logger.info("") + logger.info("SUMMARY:") + logger.info(f" Total Media Files: {self.stats['total']}") + logger.info(f" Successful Uploads: {self.stats['successful']}") + logger.info(f" Failed Uploads: {self.stats['failed']}") + logger.info(f" Skipped (Invalid): {self.stats['skipped']}") + logger.info(f" Success Rate: {(self.stats['successful'] / self.stats['total'] * 100):.2f}%" + if self.stats['total'] > 0 else " Success Rate: N/A") + logger.info("=" * 80) + + # Print failed media details + if self.stats['failed'] > 0 or self.stats['skipped'] > 0: + print("\n" + "=" * 80) + print("FAILED/SKIPPED MEDIA:") + print("=" * 80) + for result in self.results: + if not result.get('success', False): + print(f" ❌ Media ID {result['media_id']}: {result['media_name']}") + print(f" Error: {result['error']}") + print(f" Type: {result['error_type']}") + print() + + print("\n" + "=" * 80) + print(f"✅ Process completed! Check logs at: {LOG_FILE}") + print("=" * 80) + + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ + +def main(): + """Main entry point""" + try: + # Can adjust max_workers here if needed + uploader = MediaVectorStoreUploader(max_workers=4) + uploader.run() + except Exception as e: + logger.error(f"Fatal error: {str(e)}") + print(f"❌ Fatal error occurred: {str(e)}") + sys.exit(1) + + +# ============================================================================ +# USAGE EXAMPLES +# ============================================================================ + +# Example 1: Test with 1 specific media file +# uploader = MediaVectorStoreUploader( +# max_workers=4, +# bot_route="/free-flow-bot", +# media_ids=[340] +# ) +# uploader.run() + +# Example 2: Process multiple specific media files +# uploader = MediaVectorStoreUploader( +# max_workers=4, +# bot_route="/free-flow-bot", +# media_ids=[704, 705, 706, 707, 708] +# ) +# uploader.run() + +# Example 3: Process all media files +# uploader = MediaVectorStoreUploader( +# max_workers=4, +# bot_route="/free-flow-bot" +# ) +# uploader.run() + +# Example 4: Test with first 5 media files using limit +# uploader = MediaVectorStoreUploader( +# max_workers=4, +# bot_route="/free-flow-bot", +# limit=5 +# ) +# uploader.run() \ No newline at end of file diff --git a/chatbot/scripts/knowledge_service/s3_url_extractor.py b/chatbot/scripts/knowledge_service/s3_url_extractor.py new file mode 100644 index 0000000..cb4635d --- /dev/null +++ b/chatbot/scripts/knowledge_service/s3_url_extractor.py @@ -0,0 +1,205 @@ +import json +from chatbot.models import Media, KeyValue +from chatbot.models.media_models import MediaImage + + +# ============================================================================ +# SHARED SERIALIZER +# ============================================================================ +def serialize_media(media, truncate_text=False): + """ + Serialize a Media object into a unified dict + used by BOTH parents and subdocuments. + """ + + extracted_text = media.extracted_text or '' + + if truncate_text and len(extracted_text) > 200: + extracted_text = extracted_text[:200] + '...' + + # Tags + tags = [] + if hasattr(media, 'tags'): + try: + tags = list(media.tags.values_list('name', flat=True)) + except Exception: + tags = [] + + data = { + 'id': media.id, + 'name': str(media.name) if media.name else '', + 'media_type': str(media.media_type) if media.media_type else '', + 'description': str(media.description) if media.description else '', + 'priority': str(media.priority) if media.priority else '', + 'organization': media.organization.slug if media.organization else '', + 'file_url': media.get_s3_url() if hasattr(media, 'get_s3_url') else '', + 'extracted_text': extracted_text, + 'extracted_text_length': len(media.extracted_text or ''), + 'tags': tags, + 'parent_id': media.parent_id, + 'company_bot_id': media.company_bot_id, + 'created_at': str(media.created_at), + } + + # Key Values + kvs = KeyValue.objects.filter(media=media) + data['key_values'] = [ + {'key': str(kv.key), 'value': str(kv.value) if kv.value else ''} + for kv in kvs + ] + data['key_value_count'] = len(data['key_values']) + + # Images + images = MediaImage.objects.filter(media=media) + data['images'] = [ + { + 'image_url': str(img.image_url) if img.image_url else '', + 'caption': str(img.caption) if img.caption else '' + } + for img in images + ] + data['image_count'] = len(data['images']) + + return data + + +# ============================================================================ +# RECURSIVE CHILD FETCH (IMPORTANT FIXES) +# 1. Use parent_id (NOT parent=media) +# 2. Use _base_manager (NO hidden filtering) +# ============================================================================ +def get_subdocuments_recursive(media): + children = Media._base_manager.filter( + parent_id=media.id + ).order_by('created_at') + + subdocuments = [] + + for child in children: + child_dict = serialize_media(child, truncate_text=True) + + child_dict['subdocuments'] = get_subdocuments_recursive(child) + child_dict['subdocument_count'] = len(child_dict['subdocuments']) + + subdocuments.append(child_dict) + + return subdocuments + + +# ============================================================================ +# MAIN EXPORT FUNCTION +# ============================================================================ +def export_media_hierarchy(media_id=None, output_file=None, limit=None): + """ + Export media with full parent → source → subdocument hierarchy. + + Behavior: + - If media_id is provided: + → Export ONLY that tree + - If media_id is None: + → Export ALL ROOT documents (parent_id IS NULL) + """ + + # -------------------------------------------------- + # ROOT SELECTION (THIS IS WHY SINGLE VS ALL DIFFERS) + # -------------------------------------------------- + if media_id: + try: + media_queryset = [ + Media._base_manager.get(id=media_id) + ] + except Media.DoesNotExist: + print(f"Media with ID {media_id} not found") + return [] + else: + media_queryset = Media._base_manager.filter( + parent__isnull=True + ).order_by('-created_at') + + if limit: + media_queryset = media_queryset[:limit] + + exported_data = [] + + # -------------------------------------------------- + # BUILD TREES + # -------------------------------------------------- + for media in media_queryset: + media_dict = serialize_media(media) + + media_dict['subdocuments'] = get_subdocuments_recursive(media) + media_dict['subdocument_count'] = len(media_dict['subdocuments']) + + exported_data.append(media_dict) + + # -------------------------------------------------- + # SUMMARY + # -------------------------------------------------- + def count_all_subdocs(subdocs): + count = len(subdocs) + for s in subdocs: + count += count_all_subdocs(s.get('subdocuments', [])) + return count + + total_subdocs = sum( + count_all_subdocs(m['subdocuments']) for m in exported_data + ) + + print("\n" + "=" * 60) + print("EXPORT SUMMARY") + print("=" * 60) + print(f"Total root documents: {len(exported_data)}") + print(f"Total subdocuments (all levels): {total_subdocs}") + print("=" * 60) + + # -------------------------------------------------- + # TREE VIEW (DEBUG) + # -------------------------------------------------- + def print_tree(media, indent=0): + prefix = " " * indent + print(f"{prefix}📄 {media['name']} (ID: {media['id']})") + print(f"{prefix} ├─ file_url: {'YES' if media['file_url'] else 'NO'}") + print(f"{prefix} ├─ KVs: {media['key_value_count']}") + print(f"{prefix} ├─ Images: {media['image_count']}") + print(f"{prefix} └─ Subdocs: {media['subdocument_count']}") + + for sub in media.get('subdocuments', []): + print_tree(sub, indent + 1) + + print("\nHIERARCHY TREE") + print("-" * 60) + for media in exported_data: + print_tree(media) + print() + + # -------------------------------------------------- + # SAVE FILE + # -------------------------------------------------- + if output_file: + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(exported_data, f, indent=2, ensure_ascii=False) + print(f"✓ Exported to: {output_file}") + + return exported_data + + +# ============================================================================ +# USAGE EXAMPLES +# ============================================================================ + +# 1️⃣ Export ONLY one media tree (recommended for debugging) +# result = export_media_hierarchy( +# media_id=328, +# output_file='/tmp/media_328_export.json' +# ) + +# 2️⃣ Export ALL root media trees +result = export_media_hierarchy( + output_file='/tmp/all_media_export2.json' +) + +# 3️⃣ Export ALL root media trees (LIMITED) +# result = export_media_hierarchy( +# output_file='/tmp/all_media_export.json', +# limit=10 +# ) diff --git a/chatbot/scripts/knowledge_service/sync_media_to_vector_db_celery.py b/chatbot/scripts/knowledge_service/sync_media_to_vector_db_celery.py new file mode 100644 index 0000000..5508a72 --- /dev/null +++ b/chatbot/scripts/knowledge_service/sync_media_to_vector_db_celery.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" +Sync media files to vector database using Celery tasks (same as extraction logic). + +This version uses the same Celery task approach as the normal extraction flow, +which avoids nginx size limits and handles large files better. +""" + +import os +import sys +import argparse +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +import time + +# Django setup +import django +# if __name__ == '__main__': +# project_root = Path(__file__).resolve().parent.parent.parent +# sys.path.insert(0, str(project_root)) +# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') +# django.setup() + +from django.db.models import Q +from chatbot.models import Media, Company, CompanyBot +from chatbot.celery_tasks.knowledge_service.media_tasks import save_in_vector_db, update_in_vector_db +from celery.result import AsyncResult + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('sync_media_to_vector_db_celery.log') + ] +) +logger = logging.getLogger(__name__) + + +class MediaVectorDBSyncCelery: + """Sync media files using Celery tasks (same as extraction logic)""" + + def __init__( + self, + company_slug: Optional[str] = None, + company_bot_id: Optional[int] = None, + media_ids: Optional[List[int]] = None, + batch_size: int = 10, + dry_run: bool = False, + skip_errors: bool = True, + task_timeout: int = 300, + poll_interval: float = 2.0, + update_mode: bool = False + ): + """ + Initialize sync manager using Celery tasks + + Args: + company_slug: Filter by company slug + company_bot_id: Filter by company bot ID + media_ids: Specific media IDs to process + batch_size: Number of media to process in each batch + dry_run: Test mode (don't actually upsert) + skip_errors: Continue processing if errors occur + task_timeout: Maximum time to wait for each Celery task (seconds) + poll_interval: How often to check task status (seconds) + update_mode: Use update instead of upsert + """ + self.company_slug = company_slug + self.company_bot_id = company_bot_id + self.media_ids = media_ids + self.batch_size = batch_size + self.dry_run = dry_run + self.skip_errors = skip_errors + self.task_timeout = task_timeout + self.poll_interval = poll_interval + self.update_mode = update_mode + + self.stats = { + 'total_media': 0, + 'processed': 0, + 'successful': 0, + 'failed': 0, + 'timeout': 0 + } + + self.results = [] + self._validate_filters() + + def _validate_filters(self): + """Validate company and bot filters""" + if self.company_slug: + try: + self.company = Company.objects.get(slug=self.company_slug) + logger.info(f"Found company: {self.company.name} ({self.company_slug})") + print(f"✅ Found company: {self.company.name} ({self.company_slug})") + except Company.DoesNotExist: + logger.error(f"Company with slug '{self.company_slug}' not found") + raise ValueError(f"Company with slug '{self.company_slug}' not found") + + if self.company_bot_id: + try: + self.company_bot = CompanyBot.objects.get(id=self.company_bot_id) + logger.info(f"Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + print(f"✅ Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + except CompanyBot.DoesNotExist: + logger.error(f"CompanyBot with ID {self.company_bot_id} not found") + raise ValueError(f"CompanyBot with ID {self.company_bot_id} not found") + + def get_media_queryset(self): + """Get filtered media queryset""" + queryset = Media.objects.all() + + # Apply filters + if self.media_ids: + queryset = queryset.filter(id__in=self.media_ids) + print(f"🔍 Filter: Specific media IDs: {self.media_ids}") + + if self.company_slug: + queryset = queryset.filter( + Q(company_bot__company__slug=self.company_slug) | + Q(organization__slug=self.company_slug) + ) + print(f"🔍 Filter: Company slug = {self.company_slug}") + + if self.company_bot_id: + queryset = queryset.filter(company_bot_id=self.company_bot_id) + print(f"🔍 Filter: Bot ID = {self.company_bot_id}") + + # Order by ID for consistent processing + queryset = queryset.order_by('id') + + self.stats['total_media'] = queryset.count() + print(f"📊 Total media to process: {self.stats['total_media']}\n") + + return queryset + + def wait_for_task(self, task_result: AsyncResult, media_id: int) -> Dict[str, Any]: + """ + Wait for Celery task to complete and return result + + Args: + task_result: Celery AsyncResult object + media_id: Media ID being processed + + Returns: + Result dictionary + """ + elapsed = 0 + while elapsed < self.task_timeout: + if task_result.ready(): + try: + status_code = task_result.get(timeout=1) + + if 200 <= status_code < 300: + logger.info(f"Task completed successfully for media ID {media_id}: Status {status_code}") + return { + 'success': True, + 'media_id': media_id, + 'status_code': status_code, + 'message': 'Successfully processed via Celery', + 'task_id': task_result.id + } + else: + logger.error(f"Task failed for media ID {media_id}: Status {status_code}") + return { + 'success': False, + 'media_id': media_id, + 'status_code': status_code, + 'message': f'Task failed with status {status_code}', + 'task_id': task_result.id + } + except Exception as e: + logger.error(f"Error getting task result for media ID {media_id}: {str(e)}") + return { + 'success': False, + 'media_id': media_id, + 'message': f'Task error: {str(e)}', + 'task_id': task_result.id + } + + time.sleep(self.poll_interval) + elapsed += self.poll_interval + + # Timeout + logger.warning(f"Task timeout for media ID {media_id} after {self.task_timeout}s") + return { + 'success': False, + 'media_id': media_id, + 'message': f'Task timeout after {self.task_timeout}s', + 'task_id': task_result.id, + 'timeout': True + } + + def sync_single_media(self, media_id: int) -> Dict[str, Any]: + """ + Sync a single media file using Celery task + + Args: + media_id: Media ID to process + + Returns: + Result dictionary + """ + try: + logger.info(f"Processing media ID {media_id} via Celery") + + # Get media info for logging + try: + media = Media.objects.get(id=media_id) + file_name = media.file.name.split('/')[-1] if media.file else 'Unknown' + + # Get file size if available + try: + file_size_bytes = media.file.size + file_size_mb = file_size_bytes / (1024 * 1024) + logger.info(f"Media ID {media_id}: {file_name} ({file_size_mb:.2f} MB)") + except: + file_size_mb = None + logger.info(f"Media ID {media_id}: {file_name}") + except Media.DoesNotExist: + logger.error(f"Media ID {media_id} not found") + return { + 'success': False, + 'media_id': media_id, + 'message': 'Media not found' + } + + if self.dry_run: + logger.info(f"DRY RUN: Would process media ID {media_id} via Celery") + print(f" 🔍 DRY RUN: Would process media ID {media_id}") + print(f" File: {file_name}") + if file_size_mb: + print(f" Size: {file_size_mb:.2f} MB") + print(f" Method: Celery {'update' if self.update_mode else 'upsert'}") + + return { + 'success': True, + 'media_id': media_id, + 'file_name': file_name, + 'message': 'Dry run - not processed', + 'dry_run': True + } + + # Submit Celery task (same as extraction logic) + if self.update_mode: + task_result = update_in_vector_db.apply_async( + args=(media_id, self.company_slug), + countdown=0 + ) + logger.info(f"Submitted UPDATE task {task_result.id} for media ID {media_id}") + else: + task_result = save_in_vector_db.apply_async( + args=(media_id, self.company_slug), + countdown=0 + ) + logger.info(f"Submitted UPSERT task {task_result.id} for media ID {media_id}") + + print(f" 📤 Submitted Celery task {task_result.id}") + print(f" Waiting for completion (timeout: {self.task_timeout}s)...") + + # Wait for task to complete + result = self.wait_for_task(task_result, media_id) + + if result['success']: + print(f" ✅ Successfully processed media ID {media_id}") + elif result.get('timeout'): + print(f" ⏱️ Task timeout for media ID {media_id}") + else: + print(f" ❌ Failed to process media ID {media_id}") + + return result + + except Exception as e: + error_msg = str(e) + logger.exception(f"Error processing media ID {media_id}: {error_msg}") + print(f" ❌ Error processing media ID {media_id}: {error_msg}") + + return { + 'success': False, + 'media_id': media_id, + 'message': error_msg + } + + def sync_all(self) -> Dict[str, Any]: + """Sync all filtered media using Celery tasks""" + media_queryset = self.get_media_queryset() + + if self.stats['total_media'] == 0: + logger.warning("No media found to process") + print("⚠️ No media found to process!") + return self.stats + + logger.info(f"Starting Celery-based Vector DB Sync - Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"{'='*80}") + print(f"Starting Celery-based Vector DB Sync") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Method: {'UPDATE' if self.update_mode else 'UPSERT'}") + print(f"Batch size: {self.batch_size}") + print(f"Task timeout: {self.task_timeout}s") + print(f"Poll interval: {self.poll_interval}s") + print(f"{'='*80}\n") + + # Process in batches + media_ids = list(media_queryset.values_list('id', flat=True)) + + for i in range(0, len(media_ids), self.batch_size): + batch = media_ids[i:i + self.batch_size] + batch_num = (i // self.batch_size) + 1 + total_batches = (len(media_ids) + self.batch_size - 1) // self.batch_size + + logger.info(f"Processing batch {batch_num}/{total_batches}") + print(f"\n{'='*80}") + print(f"Batch {batch_num}/{total_batches} (Media IDs: {batch[0]} - {batch[-1]})") + print(f"{'='*80}") + + for media_id in batch: + self.stats['processed'] += 1 + progress = f"[{self.stats['processed']}/{self.stats['total_media']}]" + + print(f"\n{progress} Processing Media ID: {media_id}") + print(f"{'-'*80}") + + result = self.sync_single_media(media_id) + self.results.append(result) + + if result['success']: + self.stats['successful'] += 1 + elif result.get('timeout'): + self.stats['timeout'] += 1 + self.stats['failed'] += 1 + else: + self.stats['failed'] += 1 + + if not self.skip_errors: + logger.error("Stopping due to error (skip_errors=False)") + print(f"\n❌ Stopping due to error (skip_errors=False)") + self._print_summary() + return self.stats + + self._print_summary() + return self.stats + + def _print_summary(self): + """Print sync summary""" + logger.info(f"Sync Summary - Total: {self.stats['total_media']}, Successful: {self.stats['successful']}, Failed: {self.stats['failed']}, Timeout: {self.stats['timeout']}") + print(f"\n{'='*80}") + print(f"SYNC SUMMARY (Celery Mode)") + print(f"{'='*80}") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Method: {'UPDATE' if self.update_mode else 'UPSERT'}") + print(f"Total Media: {self.stats['total_media']}") + print(f"Processed: {self.stats['processed']}") + print(f"Successful: {self.stats['successful']}") + print(f"Failed: {self.stats['failed']}") + print(f"Timeout: {self.stats['timeout']}") + print(f"{'='*80}\n") + + if self.stats['failed'] > 0: + logger.error(f"Failed media count: {self.stats['failed']}") + print("Failed Media:") + for result in self.results: + if not result['success']: + media_id = result.get('media_id', 'Unknown') + message = result.get('message', 'Unknown error') + task_id = result.get('task_id', 'N/A') + logger.error(f"Failed media ID {media_id}: {message} (task: {task_id})") + print(f" ❌ Media ID {media_id}: {message}") + print(f" Task ID: {task_id}") + print() + + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser( + description='Sync media files using Celery tasks (same as extraction logic)' + ) + + parser.add_argument('--company-slug', help='Filter by company slug') + parser.add_argument('--bot-id', type=int, help='Filter by CompanyBot ID') + parser.add_argument('--media-ids', help='Comma-separated list of media IDs') + parser.add_argument('--batch-size', type=int, default=10, help='Batch size (default: 10)') + parser.add_argument('--dry-run', action='store_true', help='Test mode') + parser.add_argument('--stop-on-error', action='store_true', help='Stop on first error') + parser.add_argument('--task-timeout', type=int, default=300, help='Task timeout in seconds (default: 300)') + parser.add_argument('--poll-interval', type=float, default=2.0, help='Poll interval in seconds (default: 2.0)') + parser.add_argument('--update', action='store_true', help='Use update instead of upsert') + + args = parser.parse_args() + + # Parse media IDs + media_ids = None + if args.media_ids: + try: + media_ids = [int(x.strip()) for x in args.media_ids.split(',')] + except ValueError: + print("❌ Error: Invalid media IDs format") + sys.exit(1) + + try: + syncer = MediaVectorDBSyncCelery( + company_slug=args.company_slug, + company_bot_id=args.bot_id, + media_ids=media_ids, + batch_size=args.batch_size, + dry_run=args.dry_run, + skip_errors=not args.stop_on_error, + task_timeout=args.task_timeout, + poll_interval=args.poll_interval, + update_mode=args.update + ) + + stats = syncer.sync_all() + + exit_code = 0 if stats['failed'] == 0 else 1 + logger.info(f"Sync completed with exit code {exit_code}") + sys.exit(exit_code) + + except Exception as e: + logger.exception(f"Fatal error: {e}") + print(f"\n❌ ERROR: {e}") + sys.exit(1) + +# +# if __name__ == '__main__': +# main() + +# CALL FOR SOME MEDIA IDS ONLY +# syncer = MediaVectorDBSyncCelery( +# company_slug=None, +# media_ids=[364, 396], +# ) +# +# syncer.sync_all() \ No newline at end of file diff --git a/chatbot/scripts/knowledge_service/tag_extraction.py b/chatbot/scripts/knowledge_service/tag_extraction.py new file mode 100644 index 0000000..e859244 --- /dev/null +++ b/chatbot/scripts/knowledge_service/tag_extraction.py @@ -0,0 +1,266 @@ +import requests +import openai +import json +import re +from typing import List, Dict, Any + +def extract_tags_from_google_doc(doc_url: str, openai_api_key: str, max_tags: int = 15) -> List[str]: + """ + Extract tags from a Google Docs URL + + Args: + doc_url: Google Docs URL (https://docs.google.com/document/d/...) + openai_api_key: Your OpenAI API key + max_tags: Maximum number of tags to extract + + Returns: + List of extracted tags + """ + + def convert_to_export_url(google_docs_url: str) -> str: + """Convert Google Docs URL to plain text export URL""" + # Extract document ID from URL + doc_id_match = re.search(r'/document/d/([a-zA-Z0-9-_]+)', google_docs_url) + if not doc_id_match: + raise ValueError("Invalid Google Docs URL format") + + doc_id = doc_id_match.group(1) + # Convert to plain text export URL + export_url = f"https://docs.google.com/document/d/{doc_id}/export?format=txt" + return export_url + + def fetch_document_text(export_url: str) -> str: + """Fetch document content as plain text""" + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + response = requests.get(export_url, headers=headers, timeout=30) + response.raise_for_status() + + # Google Docs export returns text content + text = response.text.strip() + + if not text or len(text) < 10: + raise ValueError("Document appears to be empty or inaccessible") + + return text + + except requests.exceptions.RequestException as e: + raise Exception(f"Failed to fetch document: {str(e)}") + + def extract_tags_with_llm(text: str, api_key: str, max_tags: int) -> Dict[str, Any]: + """Extract tags using OpenAI LLM""" + + # Initialize OpenAI client + client = openai.OpenAI(api_key=api_key) + + # Truncate text if too long + if len(text) > 4000: + text = text[:4000] + "..." + + prompt = f""" +Analyze this document and extract relevant tags and classification information. + +Document Content: +{text} + +Extract the following and return as JSON: +1. "tags": A list of {max_tags} relevant tags (2-3 words each) that describe content, purpose, domain +2. "classification": A single phrase describing what type of document this is +3. "main_topics": A list of 3-5 main topics covered +4. "entities": A list of important organizations, people, or places mentioned + +Focus on: +- Educational and academic content +- Government and policy terms +- Monitoring & Evaluation concepts +- Field work and assessment +- Administrative content + +Return ONLY valid JSON: +{{ + "tags": ["tag1", "tag2", "tag3"], + "classification": "Document Type", + "main_topics": ["topic1", "topic2"], + "entities": ["entity1", "entity2"] +}} +""" + + try: + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[ + { + "role": "system", + "content": "You are an expert document analyzer. Extract tags and classification information in JSON format." + }, + {"role": "user", "content": prompt} + ], + max_tokens=600, + temperature=0.3 + ) + + result_text = response.choices[0].message.content.strip() + + # Parse JSON response + try: + result = json.loads(result_text) + return result + except json.JSONDecodeError: + # Fallback: extract tags using regex + tags = re.findall(r'"([^"]*)"', result_text) + return { + "tags": tags[:max_tags] if tags else ["Document", "Content"], + "classification": "Document", + "main_topics": [], + "entities": [] + } + + except Exception as e: + raise Exception(f"LLM processing failed: {str(e)}") + + # Main execution + try: + print(f"Processing Google Doc: {doc_url}") + + # Convert URL to export format + export_url = convert_to_export_url(doc_url) + print(f"Export URL: {export_url}") + + # Fetch document text + document_text = fetch_document_text(export_url) + print(f"Extracted {len(document_text)} characters") + + # Extract tags using LLM + extraction_result = extract_tags_with_llm(document_text, openai_api_key, max_tags) + + # Return just the tags as requested + tags = extraction_result.get("tags", []) + print(f"Extracted {len(tags)} tags: {tags}") + + return tags + + except Exception as e: + print(f"Error: {str(e)}") + return [] + +# Simple usage function +def get_tags_from_google_doc(doc_url: str, api_key: str) -> List[str]: + """ + Simplified function - just pass URL and get tags back + + Args: + doc_url: Google Docs URL + api_key: OpenAI API key + + Returns: + List of tags + """ + return extract_tags_from_google_doc(doc_url, api_key) + +# Example usage +if __name__ == "__main__": + # Example Google Docs URL (replace with your actual document) + google_doc_url = "https://docs.google.com/document/d/1xZbZpByp-TeysQR7M8PzqidtoaJIWxcy/edit?tab=t.0" + + # Your OpenAI API key (replace with actual key) + openai_key = "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" + + # Extract tags + tags = get_tags_from_google_doc(google_doc_url, openai_key) + + # Print results + print("Extracted Tags:") + for i, tag in enumerate(tags, 1): + print(f"{i}. {tag}") + + # Or as a simple list + print(f"\nTags as list: {tags}") + +# Alternative: Get full extraction results +def get_full_analysis_from_google_doc(doc_url: str, api_key: str) -> Dict[str, Any]: + """ + Get complete analysis including tags, classification, topics, entities + + Args: + doc_url: Google Docs URL + api_key: OpenAI API key + + Returns: + Dictionary with tags, classification, topics, entities + """ + + def convert_to_export_url(google_docs_url: str) -> str: + doc_id_match = re.search(r'/document/d/([a-zA-Z0-9-_]+)', google_docs_url) + if not doc_id_match: + raise ValueError("Invalid Google Docs URL format") + doc_id = doc_id_match.group(1) + return f"https://docs.google.com/document/d/{doc_id}/export?format=txt" + + def fetch_and_analyze(export_url: str, api_key: str) -> Dict[str, Any]: + # Fetch text + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} + response = requests.get(export_url, headers=headers, timeout=30) + response.raise_for_status() + text = response.text.strip() + + if len(text) > 4000: + text = text[:4000] + "..." + + # Analyze with LLM + client = openai.OpenAI(api_key=api_key) + + prompt = f""" +Analyze this document and return JSON with tags and classification: + +{text} + +Return: +{{ + "tags": ["tag1", "tag2", "tag3"], + "classification": "Document Type", + "main_topics": ["topic1", "topic2"], + "entities": ["entity1", "entity2"] +}} +""" + + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": prompt}], + max_tokens=500, + temperature=0.3 + ) + + try: + return json.loads(response.choices[0].message.content.strip()) + except: + return {"tags": ["Document"], "classification": "Unknown", "main_topics": [], "entities": []} + + try: + export_url = convert_to_export_url(doc_url) + return fetch_and_analyze(export_url, api_key) + except Exception as e: + return {"error": str(e), "tags": [], "classification": "Error", "main_topics": [], "entities": []} + +# Quick test function +def quick_test(): + """Quick test with a public Google Doc""" + + # Public Google Doc example (Google Sheets tutorial) + test_url = "https://docs.google.com/document/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit" + api_key = "your-openai-api-key-here" # Replace with your key + + print("Testing Google Docs tag extraction...") + + # Test simple tag extraction + tags = get_tags_from_google_doc(test_url, api_key) + print(f"Simple tags: {tags}") + + # Test full analysis + full_analysis = get_full_analysis_from_google_doc(test_url, api_key) + print(f"Full analysis: {full_analysis}") + +# Uncomment to test +# quick_test() \ No newline at end of file diff --git a/chatbot/scripts/knowledge_service/verify_vector_db_sources.py b/chatbot/scripts/knowledge_service/verify_vector_db_sources.py new file mode 100644 index 0000000..9966b44 --- /dev/null +++ b/chatbot/scripts/knowledge_service/verify_vector_db_sources.py @@ -0,0 +1,352 @@ +import os +import sys +import django +import argparse +import requests +import json +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any + +# Setup Django environment +# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') +django.setup() + +from chatbot.models import Media + +""" +Vector DB Source Verifier - Check which media documents exist in vector database + +COMMAND LINE: + python verify_vector_db_sources.py [--api-url URL] [--company-id ID] [--batch-size N] [--output FILE] + + Examples: + python verify_vector_db_sources.py + python verify_vector_db_sources.py --company-id acme --batch-size 50 + +SHELL_PLUS: + verifier = VectorDBSourceVerifier(api_base_url=None, company_id=None, batch_size=100) + verifier.run() + +ENVIRONMENT: + VECTOR_DB_BASE_URL - API base URL (required if not passed via --api-url) + +OUTPUT: + JSON file with found/not_found source IDs and summary statistics +""" + + +class VectorDBSourceVerifier: + """Verify which media documents exist in the vector database""" + + def __init__( + self, + api_base_url: str = None, + output_file: str = None, + batch_size: int = 100, + company_id: str = None + ): + """ + Initialize the verifier + + Args: + api_base_url: Base URL of the API (default: from VECTOR_DB_BASE_URL env var) + output_file: Path to output JSON file (default: verify_results_TIMESTAMP.json) + batch_size: Number of IDs to send per request + company_id: Optional company ID to filter media + """ + # Get API URL from environment if not provided + if api_base_url is None: + api_base_url = os.getenv('VECTOR_DB_BASE_URL') + if not api_base_url: + raise ValueError( + "API URL not provided. Either pass --api-url argument or set VECTOR_DB_BASE_URL environment variable" + ) + # Add http:// if not present + if not api_base_url.startswith(('http://', 'https://')): + api_base_url = f"http://{api_base_url}" + + self.api_base_url = api_base_url.rstrip('/') + self.verify_endpoint = f"{self.api_base_url}/api/documents/verify-sources" + self.batch_size = batch_size + self.company_id = company_id + + # Set default output file if not provided + if output_file is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = f"verify_results_{timestamp}.json" + + self.output_file = Path(output_file) + + print(f"{'='*80}") + print(f"Vector DB Source Verification") + print(f"{'='*80}") + print(f"API Base URL: {self.api_base_url}") + print(f"Verify Endpoint: {self.verify_endpoint}") + print(f"Output File: {self.output_file}") + print(f"Batch Size: {self.batch_size}") + if self.company_id: + print(f"Company ID Filter: {self.company_id}") + print(f"{'='*80}\n") + + def fetch_media_ids(self) -> List[str]: + """ + Fetch all media IDs from the chatbot_media table + + Returns: + List of media ID strings + """ + print("Fetching media IDs from database...") + + # Build query + queryset = Media.objects.all() + + # Filter by company if specified + if self.company_id: + queryset = queryset.filter(company_bot__company__slug=self.company_id) + + # Get IDs and convert to strings + media_ids = list(queryset.values_list('id', flat=True)) + media_ids_str = [str(id) for id in media_ids] + + print(f"✅ Found {len(media_ids_str)} media records in database") + + return media_ids_str + + def verify_sources(self, source_ids: List[str]) -> Dict[str, Any]: + """ + Call the verify-sources endpoint + + Args: + source_ids: List of source IDs to verify + + Returns: + API response as dictionary + """ + payload = { + "source_ids": source_ids + } + + print(f"\nCalling verify-sources endpoint...") + print(f"Request payload: {json.dumps(payload, indent=2)}") + + try: + response = requests.post( + self.verify_endpoint, + json=payload, + headers={'Content-Type': 'application/json'}, + timeout=60 + ) + + if response.status_code == 200: + result = response.json() + print(f"✅ API call successful") + return result + else: + print(f"❌ API call failed with status {response.status_code}") + print(f"Response: {response.text}") + return { + "error": True, + "status_code": response.status_code, + "message": response.text + } + + except requests.exceptions.RequestException as e: + print(f"❌ Network error: {str(e)}") + return { + "error": True, + "message": str(e) + } + + def process_in_batches(self, media_ids: List[str]) -> List[Dict[str, Any]]: + """ + Process media IDs in batches + + Args: + media_ids: List of all media IDs + + Returns: + List of batch results + """ + total_ids = len(media_ids) + num_batches = (total_ids + self.batch_size - 1) // self.batch_size + + print(f"\nProcessing {total_ids} IDs in {num_batches} batch(es)...") + + batch_results = [] + + for i in range(0, total_ids, self.batch_size): + batch_num = (i // self.batch_size) + 1 + batch = media_ids[i:i + self.batch_size] + + print(f"\n{'='*80}") + print(f"Batch {batch_num}/{num_batches} - Processing {len(batch)} IDs") + print(f"{'='*80}") + + result = self.verify_sources(batch) + result['batch_number'] = batch_num + result['batch_size'] = len(batch) + batch_results.append(result) + + return batch_results + + def aggregate_results(self, batch_results: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Aggregate results from all batches + + Args: + batch_results: List of batch results + + Returns: + Aggregated results dictionary + """ + aggregated = { + "total_requested": 0, + "found": [], + "not_found": [], + "found_count": 0, + "not_found_count": 0, + "batches": batch_results, + "timestamp": datetime.now().isoformat(), + "api_endpoint": self.verify_endpoint + } + + for batch in batch_results: + if not batch.get('error'): + aggregated["total_requested"] += batch.get("total_requested", 0) + aggregated["found"].extend(batch.get("found", [])) + aggregated["not_found"].extend(batch.get("not_found", [])) + + aggregated["found_count"] = len(aggregated["found"]) + aggregated["not_found_count"] = len(aggregated["not_found"]) + + return aggregated + + def save_results(self, results: Dict[str, Any]): + """ + Save results to JSON file + + Args: + results: Results dictionary to save + """ + print(f"\nSaving results to {self.output_file}...") + + with open(self.output_file, 'w') as f: + json.dump(results, f, indent=2) + + print(f"✅ Results saved successfully") + + def print_summary(self, results: Dict[str, Any]): + """ + Print summary of results + + Args: + results: Results dictionary + """ + print(f"\n{'='*80}") + print(f"VERIFICATION SUMMARY") + print(f"{'='*80}") + print(f"Total IDs Requested: {results['total_requested']}") + print(f"Found in Vector DB: {results['found_count']} ({results['found_count']/results['total_requested']*100:.1f}%)") + print(f"Not Found in Vector DB: {results['not_found_count']} ({results['not_found_count']/results['total_requested']*100:.1f}%)") + print(f"Output File: {self.output_file}") + print(f"{'='*80}\n") + + if results['not_found_count'] > 0: + print(f"⚠️ {results['not_found_count']} documents are missing from the vector database") + print(f" Check {self.output_file} for the list of missing IDs") + else: + print(f"✅ All documents are present in the vector database!") + + def run(self): + """ + Run the verification process + """ + try: + # Step 1: Fetch media IDs from database + media_ids = self.fetch_media_ids() + + if not media_ids: + print("⚠️ No media records found in database") + return + + # Step 2: Verify sources in batches + batch_results = self.process_in_batches(media_ids) + + # Step 3: Aggregate results + aggregated_results = self.aggregate_results(batch_results) + + # Step 4: Save results + self.save_results(aggregated_results) + + # Step 5: Print summary + self.print_summary(aggregated_results) + + except Exception as e: + print(f"\n❌ ERROR: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +def main(): + """Main entry point for command-line usage""" + parser = argparse.ArgumentParser( + description='Verify which media documents exist in the AI vector database', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Verify all documents (uses VECTOR_DB_BASE_URL from environment) + python verify_vector_db_sources.py + + # Override with custom API URL + python verify_vector_db_sources.py --api-url http://localhost:8000 + + # Verify documents for a specific company + python verify_vector_db_sources.py --company-id my-company + + # Specify custom output file + python verify_vector_db_sources.py --output my_results.json + + # Process in smaller batches + python verify_vector_db_sources.py --batch-size 50 + """ + ) + + parser.add_argument( + '--api-url', + help='Base URL of the API (default: from VECTOR_DB_BASE_URL environment variable)' + ) + parser.add_argument( + '--output', + help='Output JSON file path (default: verify_results_TIMESTAMP.json)' + ) + parser.add_argument( + '--batch-size', + type=int, + default=100, + help='Number of IDs to process per batch (default: 100)' + ) + parser.add_argument( + '--company-id', + help='Filter media by company slug/ID' + ) + + args = parser.parse_args() + + try: + verifier = VectorDBSourceVerifier( + api_base_url=args.api_url, + output_file=args.output, + batch_size=args.batch_size, + company_id=args.company_id + ) + + verifier.run() + + except Exception as e: + print(f"\n❌ FATAL ERROR: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/chatbot/scripts/meghaPTM/create_story_script.py b/chatbot/scripts/meghaPTM/create_story_script.py new file mode 100644 index 0000000..7d0b4aa --- /dev/null +++ b/chatbot/scripts/meghaPTM/create_story_script.py @@ -0,0 +1,737 @@ +from chatbot.models import (Profile, CompanyChat, + ChatSession, ChatStatus, BotVernacular) +from chatbot.utils.chat_utils import get_guided_chat +from chatbot.utils.shikshalokam_mitra_utils import get_stored_conversation, get_stored_chathistory +from chatbot.utils.shikshalokam_story_utils import save_shikshalokam_story + +from chatbot.utils.story_utils.format_utils import get_formatted_story +from chatbot.utils.story_utils.get_story_prompts import get_creation_promt, get_tool_values, \ + get_validation_prompt + + +from chatbot.llm_models.llm_script import handle_bedrock_model +import traceback +from chatbot.models import StoryStatusChoices, Story, CompanyBot, Voice, \ + VoiceType +from chatbot.models.geo_models import ProfileAddress +from chatbot.utils.story_llama_utils import translate_field, create_project +from chatbot.utils.story_utils.challenges_utils import handle_challenges_solutions +from chatbot.utils.story_utils.format_utils import clean_escaped_text +from chatbot.utils.story_utils.story_llm import generate_story_llm +from chatbot.utils.story_utils.story_utils import get_story_company_bot +from chatbot.utils.transliterate_utils import transliterate_text +from shikshalokam.models import Project, Task +from shikshalokam.serializer import TaskSerializer + +import asyncio +import functools +from chatbot.models import LLMProvider, SessionFlowName +import logging + + +import json +import os +from django.core.validators import URLValidator +import json_repair + + +logger = logging.getLogger('django') +validate = URLValidator() +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER')) + + + +logger = logging.getLogger('django') + +def process_mega_ptm_sessions(limit=0): + """ + Process all 'megaPTM' sessions that do not have a corresponding story. + Categorize sessions into success, failed, or in doubt. + """ + + success_sessions = [] + failed_sessions = [] + in_doubt_sessions = [] + + sessions = ChatSession.objects.filter( + session_type='megaPTM' + ).exclude( + session__in=Story.objects.values_list('session', flat=True) + ) + + if limit > 0: + sessions = sessions[:limit] + + print(f"Found {sessions.count()} sessions to process.\n") + + for session in sessions: + try: + profile_id = session.profile.id if session.profile else None + session_id = session.session + flow = session.session_type + language = 'en' + access_token = None + + print(f"Processing session: {session_id} with profile: {profile_id}") + + story_id, story_content, err_msg = create_story_object( + profile_id, session_id, access_token, flow, language + ) + + if story_id: + try: + story = Story.objects.get(id=story_id) + other_params = story.other_params or {} + + user_name = other_params.get("user_name", "").strip() + ptm_summary = other_params.get("ptm_experience_summary", "").strip() + + if user_name and ptm_summary and user_name!='' and ptm_summary !='': + success_sessions.append(session_id) + print(f"✅ Successfully processed session: {session_id}\n") + else: + in_doubt_sessions.append(session_id) + print(f"❓ In doubt: session {session_id} has missing fields.\n") + + except Story.DoesNotExist: + failed_sessions.append(session_id) + print(f"❌ Story not found for session: {session_id}\n") + else: + failed_sessions.append(session_id) + print(f"❌ Story creation failed for session: {session_id} — {err_msg}\n") + + except Exception as e: + failed_sessions.append(session.session) + print(f"❌ Exception while processing session {session.session}: {str(e)}\n") + + # Summary + print("\n--- Summary ---") + print(f"✅ Success Count: {len(success_sessions)}") + print(f"❌ Failure Count: {len(failed_sessions)}") + print(f"❓ In Doubt Count: {len(in_doubt_sessions)}") + + print("\nSuccessful Sessions:\n", success_sessions) + print("\nFailed Sessions:\n", failed_sessions) + print("\nIn Doubt Sessions:\n", in_doubt_sessions) + + return { + "success": success_sessions, + "failed": failed_sessions, + "in_doubt": in_doubt_sessions, + } + + +# Example usage: +# process_mega_ptm_sessions() # Process all +# process_mega_ptm_sessions(limit=1) # Only process first session + + + +def create_story_object(profile_id, session, access_token, flow, language='en'): + voice_provider=None + company_bot=None + try: + profile = Profile.objects.filter(id=profile_id).first() + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + + company_bot, validate_bot = get_story_company_bot(profile=profile, flow=flow) + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + chat_session = ChatSession.objects.get(session=session) + + formatted_content_prompt, formatted_story_prompt, tag_context, project_data = get_creation_promt( + company_bot=company_bot, profile=profile + ) + + intro_to_pass = None + + if flow and flow in [SessionFlowName.GuestMiStory]: + flow_company_bot = CompanyBot.objects.get(company=profile.company, route='/guided_guest') + bot_vernacular = BotVernacular.objects.filter(company_bot=flow_company_bot).first() + if bot_vernacular: + intro_to_pass = bot_vernacular.introductory_message + + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats, intro=intro_to_pass + ) + + tool_content, tool_story = get_tool_values(company_bot=company_bot) + + response_json_content, response_json_story = asyncio.run( + generate_story_llm( + formatted_content_prompt=formatted_content_prompt, formatted_story_prompt=formatted_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=company_bot, + flow=flow + ) + ) + + validate_content_prompt, validate_story_prompt = get_validation_prompt( + response_json_story=response_json_story, validate_bot=validate_bot, + response_json_content=response_json_content, tag_context=tag_context, project_data=project_data, + profile=profile + ) + + tool_content, tool_story = get_tool_values(company_bot=validate_bot) + + if company_bot.provider != validate_bot.provider: + messages = get_guided_chat( + company_bot=validate_bot, company_chats=company_chats, intro=intro_to_pass + ) + + response_json_story, combined_reason = asyncio.run( + validate_story_llm( + formatted_content_prompt=validate_content_prompt, formatted_story_prompt=validate_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=validate_bot, + flow=flow + ) + ) + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.GuestMiStory, SessionFlowName.Reflection, + SessionFlowName.SsoFlow]: + story, problem_statement = save_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + project_id=chat_session.project_id, company_bot=company_bot + ) + elif flow == SessionFlowName.megaPTM: + story, problem_statement = save_ptm_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + company_bot=company_bot + ) + else: + story, problem_statement = save_chaupal_report( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + messages=messages, company_bot=company_bot + ) + if story: + formatted_content = get_formatted_story(story) + if formatted_content: + story.formatted_content = formatted_content + story.save(update_fields=['formatted_content']) + + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save(update_fields=['session_status']) + chat_session.save_title(language=language) + + if flow == SessionFlowName.Reflection: + conversation = get_stored_conversation(company_chats=company_chats) + chat_history = get_stored_chathistory(company_chats=company_chats) + else: + conversation, chat_history = [], [] + + save_shikshalokam_story( + story=story, profile=profile, + problem_statement=problem_statement, chat_history=chat_history, access_token=access_token, + project_id=None, session=session, conversation=conversation, flow=flow + ) + + story_id = story.id if story and story.id else "" + story_content = story.content if story and story.content else "" + + return story_id, story_content, "" + + except Exception as e: + traceback.print_exc() + if not company_bot: + profile = Profile.objects.filter(id=profile_id).first() + company_bot, validate_bot = get_story_company_bot(flow=flow) + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message \ + else "Please try again!" + if voice_provider and language != 'en': + error_message = translate_field( + voice_provider=voice_provider, message_body=error_message, target_language=language + ) + return "", "", error_message + + +def save_story( + response_json_story, language, voice_provider, profile, session, combined_reason, flow=None, project_id=None, + company_bot=None +): + try: + title = response_json_story['title'] + tweet = response_json_story.get('tweet', '') + objective = response_json_story['objective'] + action_steps = response_json_story['action_steps'] + impact = response_json_story.get('impact', '') + micro_improvement = response_json_story.get('micro_improvement', '') + problem_statement = response_json_story['problem_statement'] + + duration = response_json_story.get('duration', '') + + content = response_json_story['content'] + blurb = response_json_story.get('blurb', '') + content = clean_escaped_text(text=content) + title = clean_escaped_text(text=title) + objective = clean_escaped_text(text=objective) + blurb = clean_escaped_text(text=blurb) + impact = clean_escaped_text(text=impact) + problem_statement = clean_escaped_text(text=problem_statement) + + if flow and flow in [SessionFlowName.GuestMiStory]: + user_name = response_json_story.get('user_name', '') + location = response_json_story.get('location', '') + organization = response_json_story.get('organization', '') + designation = response_json_story.get('designation', '') + else: + user_name=profile.first_name if profile and profile.first_name else '' + organization=None + designation=None + location = None + if profile: + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + location = ", ".join(location_parts) + else: + location = "" + + if not title or not objective or not action_steps or not problem_statement: + raise Exception("Empty fields found") + + logger.info(f"language used: %s", language) + if language != 'en': + title = translate_field( + voice_provider=voice_provider, message_body=title, target_language=language + ) + tweet = translate_field( + voice_provider=voice_provider, message_body=tweet, target_language=language + ) + objective = translate_field( + voice_provider=voice_provider, message_body=objective, target_language=language + ) + if isinstance(action_steps, str): + action_steps = translate_field( + voice_provider=voice_provider, message_body=action_steps, target_language=language + ) + else: + action_steps = [ + translate_field( + voice_provider=voice_provider, + message_body=action_step, + target_language=language + ) + for action_step in action_steps + ] + + impact = translate_field( + voice_provider=voice_provider, message_body=impact, target_language=language + ) + micro_improvement = translate_field( + voice_provider=voice_provider, message_body=micro_improvement, target_language=language + ) + problem_statement = translate_field( + voice_provider=voice_provider, message_body=problem_statement, target_language=language + ) + content = translate_field( + voice_provider=voice_provider, message_body=content, target_language=language + ) + blurb = translate_field( + voice_provider=voice_provider, message_body=blurb, target_language=language + ) + if flow and flow in [SessionFlowName.GuestMiStory] and company_bot: + voice_transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + if user_name and user_name != '': + is_sentence = ' ' in user_name + user_name = transliterate_text( + voice_provider=voice_transliterate_provider, message_body=user_name, target_language=language, + source_language='en', + is_sentence=is_sentence + ) + user_name = get_transliteration_output(data=user_name) + if organization and organization != '': + is_sentence = ' ' in organization + organization = transliterate_text( + voice_provider=voice_transliterate_provider, message_body=organization, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + organization = get_transliteration_output(data=organization) + if designation and designation != '': + is_sentence = ' ' in designation + designation = transliterate_text( + voice_provider=voice_transliterate_provider, message_body=designation, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + designation = get_transliteration_output(data=designation) + + + if flow == SessionFlowName.Reflection and project_id: + logger.info(f"project_id: %s", project_id) + project = Project.objects.get(project_id=project_id) + if project: + tasks = Task.objects.filter(project=project) + serialized_tasks = TaskSerializer(tasks, many=True).data + # action_steps = [task.get('task_name') for task in serialized_tasks] + action_steps = [f"{idx + 1}. {task.get('task_name')}" for idx, task in enumerate(serialized_tasks)] + + other_params = { + 'duration': duration, + 'flow': flow, + 'user_name': user_name, + } + + if flow and flow in [SessionFlowName.GuestMiStory]: + other_params['user_name'] = user_name + other_params['location'] = location + other_params['organization'] = organization + other_params['designation'] = designation + + story = Story.objects.filter(session=session).first() + if story: + story.title = title + story.content = content + story.tweet = tweet + story.author = profile + story.objective = objective + story.action_steps = action_steps + story.impact = impact + story.micro_improvement = micro_improvement + story.language = language + story.stage = StoryStatusChoices.COMPLETED + story.other_params = other_params + story.location = location if location else "" + story.blurb = blurb + story.validation_logs = combined_reason + else: + story = Story( + title=title, + content=content, + tweet=tweet, + author=profile, + session=session, + objective=objective, + action_steps=action_steps, + impact=impact, + micro_improvement=micro_improvement, + language=language, + stage=StoryStatusChoices.COMPLETED, + other_params=other_params, + location=location if location else "", + blurb=blurb, + validation_logs=combined_reason + ) + story.save() + + create_project( + response_json=response_json_story, title=title, objective=objective, story=story, + profile=profile, problem_statement=problem_statement, language=language, voice_provider=voice_provider, + project_id=project_id + ) + + return story, problem_statement + except Exception as e: + logger.error('Error Occured: %s', e, exc_info=True) + traceback.print_exc() + raise Exception("Failed to save mi story") + + +def save_chaupal_report( + response_json_story, language, company_bot, voice_provider, profile, session, combined_reason, flow=None, messages=[] +): + try: + title = response_json_story['title'] + challenges_faced = response_json_story['challenges_faced'] + solutions_discussed = response_json_story['solutions_discussed'] + + user_name = response_json_story.get('user_name', '') + user_location = response_json_story.get('location', '') + organization = response_json_story.get('organization', '') + participants_count = response_json_story.get('participants_count', '') + discussion_date = response_json_story.get('discussion_date', '') + + title = clean_escaped_text(text=title) + if solutions_discussed and len(solutions_discussed) > 0 and challenges_faced and len(challenges_faced) > 0: + challenges_faced, solutions_discussed = handle_challenges_solutions( + challenges_faced=challenges_faced, solutions_discussed=solutions_discussed, profile=profile, + messages=messages + ) + + logger.info(f"language used: %s", language) + if language != 'en': + voice_transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + if user_name and user_name != '': + user_name = transliterate_text( + voice_provider=voice_transliterate_provider, message_body=user_name, target_language=language, + source_language='en' + ) + user_name=get_transliteration_output(data=user_name) + if organization and organization != '': + organization = transliterate_text( + voice_provider=voice_transliterate_provider, message_body=organization, target_language=language, + source_language='en' + ) + organization = get_transliteration_output(data=organization) + title = translate_field( + voice_provider=voice_provider, message_body=title, target_language=language + ) + if isinstance(challenges_faced, str): + challenges_faced = json_repair.repair_json(challenges_faced, return_objects=True) + + challenges_faced = [ + translate_field( + voice_provider=voice_provider, + message_body=challenge, + target_language=language + ) + for challenge in challenges_faced + ] + + if isinstance(solutions_discussed, str): + solutions_discussed = json_repair.repair_json(solutions_discussed, return_objects=True) + + solutions_discussed = [ + translate_field( + voice_provider=voice_provider, + message_body=solution, + target_language=language + ) + for solution in solutions_discussed + ] + + if profile: + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + location = ", ".join(location_parts) + else: + location = "" + else: + location = "" + + other_params = { + 'challenges_faced': challenges_faced, + 'solutions_discussed': solutions_discussed, + 'user_name': user_name, + 'location': user_location, + 'organization': organization, + 'participants_count': participants_count, + 'discussion_date': discussion_date, + 'flow': flow + } + + story = Story.objects.filter(session=session).first() + if story: + story.title = title + story.other_params = other_params + story.stage = StoryStatusChoices.COMPLETED + story.location = location + story.validation_logs = combined_reason + else: + story = Story( + title=title, + author=profile, + session=session, + stage=StoryStatusChoices.COMPLETED, + location=location, + validation_logs=combined_reason, + language=language, + other_params=other_params + ) + story.save() + + return story, None + except Exception as e: + logger.error('Error Occured: %s', e, exc_info=True) + traceback.print_exc() + raise Exception("Failed to save chaupal report") + + +def get_transliteration_output(data): + if data and isinstance(data, dict): + data = data.get('content', []) + if data and isinstance(data, list) and len(data) > 0: + return data[0] + + return None + + +def save_ptm_story( + response_json_story, language, voice_provider, profile, session, combined_reason, flow=None, + company_bot=None +): + try: + name = response_json_story.get("name", "") + district = response_json_story.get("district", "") + school = response_json_story.get("school", "") + role = response_json_story.get("role", "") + ptm_experience_summary = response_json_story.get("ptm_experience_summary", "") + key_highlights = response_json_story.get("key_highlights", "") + perceived_changes_or_impact = response_json_story.get("perceived_changes_or_impact", "") + + # if language != "en": + # ptm_experience_summary = translate_field(voice_provider, ptm_experience_summary, target_language=language) + # key_highlights = translate_field(voice_provider, key_highlights, target_language=language) + # expected_impact = translate_field(voice_provider, expected_impact, target_language=language) + # + # voice_transliterate_provider = Voice.objects.filter( + # company_bot=company_bot, type=VoiceType.Transliterate, language=language + # ).first() + # + # if name and name != '': + # is_sentence = ' ' in name + # name = transliterate_text( + # voice_provider=voice_transliterate_provider, message_body=name, target_language=language, + # source_language='en', + # is_sentence=is_sentence + # ) + # name = get_transliteration_output(data=name) + # + # name = translate_field(voice_provider, name, target_language=language) + # district = translate_field(voice_provider, district, target_language=language) + # school = translate_field(voice_provider, school, target_language=language) + # role = translate_field(voice_provider, role, target_language=language) + + other_params = { + "user_name": name, + "district": district, + "school": school, + "role": role, + "ptm_experience_summary": ptm_experience_summary, + "key_highlights": key_highlights, + "perceived_changes_or_impact": perceived_changes_or_impact, + "flow": flow, + } + + title = f"{name}'s PTM Reflection" if name and name != '' else "PTM Reflection" + + story = Story.objects.filter(session=session).first() + if story: + story.title = title + story.language = language + story.stage = StoryStatusChoices.COMPLETED + story.other_params = other_params + story.validation_logs = combined_reason + else: + story = Story( + title=title, + author=profile, + session=session, + language=language, + stage=StoryStatusChoices.COMPLETED, + other_params=other_params, + validation_logs=combined_reason + ) + story.save() + return story, ptm_experience_summary + except Exception as e: + logger.error("Error in save_ptm_story: %s", e, exc_info=True) + traceback.print_exc() + raise Exception("Failed to save PTM story") + + + +async def validate_story_llm(formatted_content_prompt, formatted_story_prompt, messages, tool_content, tool_story, + company_bot, flow): + async def func1(): + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_content_prompt, + messages=messages, + tools=tool_content, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + + + async def func2(): + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_story_prompt, + messages=messages, + tools=tool_story, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + + + + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.GuestMiStory, SessionFlowName.Reflection, + SessionFlowName.megaPTM, SessionFlowName.SsoFlow + ]: + response_json_content, response_json_story = await asyncio.gather(func1(), func2()) + else: + response_json_content = await func1() + response_json_story = None + logger.info(f"Validation: response_json_content: %s", response_json_content) + logger.info(f"Validation: response_json_story: %s", response_json_story) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + for response in [response_json_content, response_json_story]: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + elif company_bot.provider == LLMProvider.OPENAI: + pass + reason_content="" + reason_content = response_json_content.get('reason') + response_json_content = response_json_content.get('final_answer') + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + reason_story="" + if response_json_story: + reason_story = response_json_story.get('reason') + response_json_story = response_json_story.get('final_answer') + if response_json_story and isinstance(response_json_story, str): + response_json_story = json_repair.repair_json(response_json_story, return_objects=True) + + logger.info(f"Final Validation: response_json_content: %s", response_json_content) + logger.info(f"Final Validation: response_json_story: %s", response_json_story) + + if (isinstance(response_json_story, dict) and response_json_story.get("type") and + "value" in response_json_story): + value = response_json_story.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_story = value + + if (isinstance(response_json_content, dict) and response_json_content.get("type") and + "value" in response_json_content): + value = response_json_content.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + + combined_result = {**(response_json_content or {}), **(response_json_story or {})} + + combined_reason = { + "reason_content": reason_content, + "reason_story": reason_story + } + + return combined_result, combined_reason + + +def retry_if_result_none(result): + return result is None \ No newline at end of file diff --git a/chatbot/scripts/meghaPTM/translate_chats.py b/chatbot/scripts/meghaPTM/translate_chats.py new file mode 100644 index 0000000..5e86609 --- /dev/null +++ b/chatbot/scripts/meghaPTM/translate_chats.py @@ -0,0 +1,95 @@ +from chatbot.models import ChatSession, CompanyChat, CompanyBot, Voice, VoiceType, ChatType +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.utils.transliterate_utils import transliterate_text +from django.utils.timezone import make_aware +from datetime import datetime + +###STEPS TO CALL: + #CALL process_mega_ptm_chats() and this will run the whole script + +def translate_field(voice_provider, message_body, target_language, source_language="en"): + if not message_body: + return message_body + response = text_translate_provider( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language + ) + if response.get("status") == 200: + return response.get("content") + return message_body + +def transliterate_field(voice_provider, message_body, target_language, source_language="en", is_sentence=False): + if not message_body: + return message_body + response = transliterate_text( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language, is_sentence=is_sentence + ) + if response.get("status") == 200: + res = response.get('content') + if res and isinstance(res, list): + res = res[0] + return res + return message_body + + +def process_mega_ptm_chats(): + start_time = make_aware(datetime(2025, 7, 1, 0, 0)) + end_time = make_aware(datetime(2025, 7, 17, 23, 59, 59)) + + company_bot = CompanyBot.objects.get(route='/mega_ptm') + translate_voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText + ).first() + transliterate_voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate + ).first() + + sessions = ChatSession.objects.filter( + session_type=ChatType.megaPTM, + created_at__range=(start_time, end_time) + ) + + for session in sessions: + company_chats = CompanyChat.objects.filter( + session=session.session + ).order_by('created_at') + + if not company_chats.exists(): + continue + + # if company_chats.filter(translated_message__isnull=False).exclude(translated_message="").exists(): + # print(f"Skipping session {session.session}: Already translated.") + # continue + + language = None + for chat in company_chats: + if chat.other_params and chat.other_params.get("language"): + language = chat.other_params.get("language") + break + + if not language or language == "en": + print(f"Skipping session {session.session}: Language is 'en' or not found.") + continue + + for chat in company_chats: + sequence = chat.other_params.get("sequence") if chat.other_params else None + print("len: ", len(chat.message.strip().split())) + if len(chat.message.strip().split()) == 1 and sequence in [1, 2, 3] and chat.other_params.get('message_type') != "question": + word_count = len(chat.message.strip().split()) + is_sentence = word_count > 1 + translated = transliterate_field( + voice_provider=transliterate_voice_provider, message_body=chat.message, + target_language="en", source_language=language, is_sentence=is_sentence + ) + else: + translated = translate_field( + voice_provider=translate_voice_provider, message_body=chat.message, + target_language="en", source_language=language + ) + + chat.translated_message = translated + chat.save(update_fields=["translated_message"]) + print(f"✅ Session {session.session} | Chat ID {chat.id} translated.") + + print("✅ Mega PTM translation process complete.") diff --git a/chatbot/scripts/mi_guest_flow/create_story_script.py b/chatbot/scripts/mi_guest_flow/create_story_script.py new file mode 100644 index 0000000..7fb3c2e --- /dev/null +++ b/chatbot/scripts/mi_guest_flow/create_story_script.py @@ -0,0 +1,108 @@ +from chatbot.models import Story, ChatSession, CompanyBot, ChatStatus, SessionFlowName +from chatbot.models.company_models import CompanyStateMachine +import logging +from django.utils.timezone import make_aware +from datetime import datetime +from chatbot.utils.story_utils.story_utils import create_story_object + +logger = logging.getLogger('django') + +###Steps To Follow: + #First step is to call get_session_count() for given bots (Adjust the date as needed) + #Second step is to call create_specific_stories() and pass the valid session data we collected in First Step to + #create missing stories + + +def get_session_count(): + start_time = make_aware(datetime(2025, 6, 1, 0, 0)) + end_time = make_aware(datetime(2025, 7, 10, 23, 59, 59)) + + bots = CompanyBot.objects.filter(route__in=["/oneshot_guest", "/guided_guest"]) + + bot_steps_map = { + bot.id: CompanyStateMachine.objects.filter(company_bot=bot).count() + for bot in bots + } + + print("bot_steps_map: ", bot_steps_map) + + sessions = ChatSession.objects.filter( + created_at__range=(start_time, end_time), + company_bot__in=bots + ).exclude( + session_status=ChatStatus.COMPLETED + ).order_by('created_at').select_related('profile') + + valid_sessions = [ + session for session in sessions + if ( + session.current_step is not None + and bot_steps_map.get(session.company_bot_id) is not None + and session.current_step >= bot_steps_map[session.company_bot_id] + and not Story.objects.filter(session=session.session).exists() + ) + ] + + if valid_sessions: + print("First valid session:", valid_sessions[0].session) + print("Last valid session:", valid_sessions[-1].session) + else: + print("No valid sessions found.") + + return valid_sessions + + +def create_specific_stories(sessions, access_token): + """ + For each valid ChatSession: + - Pick profile_id, session string, and other context + - Create story + - Fix metadata + - Collect success and failure lists + """ + succeeded_sessions = [] + failed_sessions = [] + + for session in sessions: + try: + profile_id = session.profile.id if session.profile else None + session_str = session.session + print(f"session.language: {session.language}") + language = session.language if session.language else "en" + flow = session.session_type + existing_story = Story.objects.filter(session=session_str).first() + if existing_story and existing_story.other_params and existing_story.other_params.get('flow'): + flow = existing_story.other_params.get('flow') + + valid_flows = [choice[0] for choice in SessionFlowName.choices] + if flow not in valid_flows: + print(f"❌ Invalid flow '{flow}' for session {session_str}, failing session") + failed_sessions.append(session_str) + continue + + print(f"Passing, profile_id: {profile_id} & session_str: {session_str} & access_token: {access_token} & " + f"flow: {flow} & language: {language}") + story_id, content, error_msg = create_story_object( + profile_id=profile_id, + session=session_str, + access_token=access_token, + flow=flow, + language=language + ) + + if story_id: + print(f"✅ Story created for session {session_str}: ID = {story_id}") + succeeded_sessions.append(session_str) + else: + print(f"❌ Failed to create story for session {session_str}: {error_msg}") + failed_sessions.append(session_str) + + except Exception as e: + print(f"🔥 Exception for session {session.session}: {str(e)}") + failed_sessions.append(session.session) + + print(f"\n✅ Succeeded sessions: {succeeded_sessions}") + print(f"❌ Failed sessions: {failed_sessions}") + + return succeeded_sessions, failed_sessions + diff --git a/chatbot/scripts/mi_guest_flow/output/identify_non_english_story.py b/chatbot/scripts/mi_guest_flow/output/identify_non_english_story.py new file mode 100644 index 0000000..92c79a6 --- /dev/null +++ b/chatbot/scripts/mi_guest_flow/output/identify_non_english_story.py @@ -0,0 +1,210 @@ +import re +from chatbot.models import Story, SessionFlowName, ChatSession + +# English letters (Hinglish allowed) +ENGLISH_LETTER_REGEX = re.compile(r'[A-Za-z]') + +# Any alphabetic letter (Latin + Devanagari + other scripts) +ANY_LETTER_REGEX = re.compile(r'[A-Za-z\u00C0-\u024F\u0900-\u097F]') + +# Text fields in Story model for GuestMiStory +TEXT_FIELDS = [ + "title", + "content", + "blurb", + "tweet", + "objective", + "action_steps", # Can be string or list + "impact", + "micro_improvement", + "location", + "district", + "state", + "block", + "formatted_content", + "summary", +] + +# Other_params fields specific to GuestMiStory +OTHER_PARAMS_FIELDS = [ + "user_name", + "location", + "organization", + "designation", + "duration" +] + + +def has_non_english_letters(text): + """ + Returns True ONLY if: + - text contains alphabetic letters + - AND contains NO English letters (A-Z) + """ + if not text or not isinstance(text, str): + return False + + # Ignore numbers, dates, symbols + if not ANY_LETTER_REGEX.search(text): + return False + + # Letters exist but none are English → non-English + return not ENGLISH_LETTER_REGEX.search(text) + + +def check_list_for_non_english(lst): + """Check if any item in list contains non-English text""" + if not isinstance(lst, list): + return False + + for item in lst: + if isinstance(item, str) and has_non_english_letters(item): + return True + return False + + +def contains_non_english_in_json(obj, specific_fields=None): + """ + Recursively scan JSON (dict / list / str) for non-English text. + If specific_fields provided, only check those fields in dict. + """ + if isinstance(obj, str): + return has_non_english_letters(obj) + + if isinstance(obj, dict): + if specific_fields: + # Only check specific fields + for field in specific_fields: + value = obj.get(field) + if value and contains_non_english_in_json(value): + return True + else: + # Check all fields + for key, value in obj.items(): + if contains_non_english_in_json(key): + return True + if contains_non_english_in_json(value): + return True + + if isinstance(obj, list): + for item in obj: + if contains_non_english_in_json(item): + return True + + return False + + +def count_non_english_stories(): + stories = Story.objects.filter( + other_params__flow=SessionFlowName.GuestMiStory + ) + + total_stories = stories.count() + + non_english_story_ids = [] + non_english_but_session_en_ids = [] + field_statistics = {} # Track which fields have non-English content + + for story in stories: + found_non_english = False + non_english_fields = [] + + # 1. Check Story fields + for field in TEXT_FIELDS: + value = getattr(story, field, None) + + # Special handling for action_steps (can be string or list) + if field == "action_steps": + if isinstance(value, str): + if has_non_english_letters(value): + found_non_english = True + non_english_fields.append(field) + elif isinstance(value, list): + if check_list_for_non_english(value): + found_non_english = True + non_english_fields.append(field) + else: + if has_non_english_letters(value): + found_non_english = True + non_english_fields.append(field) + + # 2. Check specific other_params fields for GuestMiStory + if story.other_params: + for param_field in OTHER_PARAMS_FIELDS: + value = story.other_params.get(param_field) + if value and has_non_english_letters(value): + found_non_english = True + non_english_fields.append(f"other_params.{param_field}") + + if found_non_english: + non_english_story_ids.append(story.id) + + # Track field statistics + for field in non_english_fields: + if field not in field_statistics: + field_statistics[field] = 0 + field_statistics[field] += 1 + + # Check ChatSession.language == 'en' + chat_session = ChatSession.objects.filter( + session=story.session + ).only("language").first() + + if chat_session and chat_session.language == "en": + non_english_but_session_en_ids.append(story.id) + + # ---------- STATS ---------- + non_english_count = len(non_english_story_ids) + non_english_but_en_count = len(non_english_but_session_en_ids) + + print("=" * 70) + print("GUEST MI STORY - NON-ENGLISH CONTENT ANALYSIS") + print("=" * 70) + print("Flow:", SessionFlowName.GuestMiStory) + print("Total stories:", total_stories) + + print("\n--- Non-English Content ---") + print("Count:", non_english_count) + percentage = round((non_english_count / total_stories) * 100, 2) if total_stories else 0 + print("Percentage:", f"{percentage}%") + + # Show sample IDs if too many + if len(non_english_story_ids) > 20: + print("Sample Story IDs (first 20):", non_english_story_ids[:20]) + print(f"... and {len(non_english_story_ids) - 20} more") + else: + print("Story IDs:", non_english_story_ids) + + print("\n--- Field-wise Statistics ---") + if field_statistics: + sorted_fields = sorted(field_statistics.items(), key=lambda x: x[1], reverse=True) + for field, count in sorted_fields: + field_percentage = round((count / non_english_count) * 100, 2) if non_english_count else 0 + print(f" {field}: {count} stories ({field_percentage}%)") + + print("\n--- Non-English BUT ChatSession.language = 'en' ---") + print("Count:", non_english_but_en_count) + percentage_en = round((non_english_but_en_count / total_stories) * 100, 2) if total_stories else 0 + print("Percentage:", f"{percentage_en}%") + + if len(non_english_but_session_en_ids) > 20: + print("Sample Story IDs (first 20):", non_english_but_session_en_ids[:20]) + print(f"... and {len(non_english_but_session_en_ids) - 20} more") + else: + print("Story IDs:", non_english_but_session_en_ids) + + print("=" * 70) + + return { + "total": total_stories, + "non_english_count": non_english_count, + "non_english_percentage": percentage, + "non_english_but_en_count": non_english_but_en_count, + "non_english_but_en_percentage": percentage_en, + "field_statistics": field_statistics + } + + +# Run +if __name__ == "__main__": + count_non_english_stories() diff --git a/chatbot/scripts/mi_guest_flow/output/update_non_english_story.py b/chatbot/scripts/mi_guest_flow/output/update_non_english_story.py new file mode 100644 index 0000000..0504173 --- /dev/null +++ b/chatbot/scripts/mi_guest_flow/output/update_non_english_story.py @@ -0,0 +1,339 @@ +import re +import logging +from datetime import datetime + +from chatbot.models import ( + Story, ChatSession, SessionFlowName, Voice, VoiceType +) +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.transliterate_utils import transliterate_text, get_transliteration_output + +logger = logging.getLogger("django") + +OUTPUT_FILE = "guest_mi_story_fix_report.txt" + +# ---------- LANGUAGE DETECTION ---------- +ENGLISH_LETTER_REGEX = re.compile(r'[A-Za-z]') +ANY_LETTER_REGEX = re.compile(r'[A-Za-z\u00C0-\u024F\u0900-\u097F]') + + +def is_non_english_text(text): + """Check if text contains non-English characters""" + if not text or not isinstance(text, str): + return False + # Ignore numbers / dates / symbols + if not ANY_LETTER_REGEX.search(text): + return False + return not ENGLISH_LETTER_REGEX.search(text) + + +def find_non_english_in_list(lst, field_name): + """Check if any item in list contains non-English text""" + found = [] + if not isinstance(lst, list): + return found + + for i, item in enumerate(lst): + if isinstance(item, str) and is_non_english_text(item): + found.append(f"{field_name}[{i}]") + return found + + +# ---------- MAIN SCRIPT ---------- +def fix_guest_mi_story_stories(): + stories = Story.objects.filter( + other_params__flow=SessionFlowName.GuestMiStory + ) + + total = stories.count() + fixed = [] + failed = [] + skipped = [] + + for story in stories: + offending_fields = [] + + # 🔹 CHECK MAIN STORY FIELDS + if is_non_english_text(story.title): + offending_fields.append("story.title") + + if is_non_english_text(story.content): + offending_fields.append("story.content") + + if is_non_english_text(story.objective): + offending_fields.append("story.objective") + + if is_non_english_text(story.impact): + offending_fields.append("story.impact") + + if is_non_english_text(story.micro_improvement): + offending_fields.append("story.micro_improvement") + + if is_non_english_text(story.tweet): + offending_fields.append("story.tweet") + + if is_non_english_text(story.blurb): + offending_fields.append("story.blurb") + + if is_non_english_text(story.location): + offending_fields.append("story.location") + + # Check action_steps (can be string or list) + if isinstance(story.action_steps, str): + if is_non_english_text(story.action_steps): + offending_fields.append("story.action_steps") + elif isinstance(story.action_steps, list): + offending_fields.extend(find_non_english_in_list(story.action_steps, "story.action_steps")) + + # 🔹 CHECK OTHER_PARAMS FIELDS + if story.other_params: + # Check personal info fields that should be transliterated + for field in ["user_name", "location", "organization", "designation"]: + val = story.other_params.get(field) + if val and is_non_english_text(val): + offending_fields.append(f"other_params.{field}") + + # Check duration field + if is_non_english_text(story.other_params.get("duration")): + offending_fields.append("other_params.duration") + + if not offending_fields: + skipped.append(story.id) + continue + + # ---------- GET SOURCE LANGUAGE ---------- + chat_session = ChatSession.objects.filter( + session=story.session + ).only("language").first() + + source_language = chat_session.language if chat_session else "en" + + # ❌ HARD FAIL RULE + if source_language == "en": + failed.append({ + "story_id": story.id, + "reason": "Non-English detected but ChatSession.language = en", + "fields": offending_fields + }) + continue + + # ---------- GET VOICE PROVIDERS ---------- + # Get company_bot from chat_session if available + company_bot = chat_session.company_bot if chat_session else None + + translation_provider = Voice.objects.filter( + type=VoiceType.TextToText, + language=source_language + ) + if company_bot: + translation_provider = translation_provider.filter(company_bot=company_bot) + translation_provider = translation_provider.first() + + transliteration_provider = Voice.objects.filter( + type=VoiceType.Transliterate, + language=source_language + ) + if company_bot: + transliteration_provider = transliteration_provider.filter(company_bot=company_bot) + transliteration_provider = transliteration_provider.first() + + updated = False + other_params = story.other_params or {} + + # ---------- TRANSLATE MAIN STORY FIELDS ---------- + if is_non_english_text(story.title): + story.title = translate_field( + voice_provider=translation_provider, + message_body=story.title, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.content): + story.content = translate_field( + voice_provider=translation_provider, + message_body=story.content, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.objective): + story.objective = translate_field( + voice_provider=translation_provider, + message_body=story.objective, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.impact): + story.impact = translate_field( + voice_provider=translation_provider, + message_body=story.impact, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.micro_improvement): + story.micro_improvement = translate_field( + voice_provider=translation_provider, + message_body=story.micro_improvement, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.tweet): + story.tweet = translate_field( + voice_provider=translation_provider, + message_body=story.tweet, + target_language="en", + source_language=source_language + ) + updated = True + + if is_non_english_text(story.blurb): + story.blurb = translate_field( + voice_provider=translation_provider, + message_body=story.blurb, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- HANDLE ACTION_STEPS ---------- + if isinstance(story.action_steps, str): + if is_non_english_text(story.action_steps): + story.action_steps = translate_field( + voice_provider=translation_provider, + message_body=story.action_steps, + target_language="en", + source_language=source_language + ) + updated = True + elif isinstance(story.action_steps, list): + new_action_steps = [] + for step in story.action_steps: + if step and is_non_english_text(step): + step = translate_field( + voice_provider=translation_provider, + message_body=step, + target_language="en", + source_language=source_language + ) + updated = True + new_action_steps.append(step) + story.action_steps = new_action_steps + + # ---------- TRANSLITERATE LOCATION (main field) ---------- + if is_non_english_text(story.location): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=story.location, + target_language="en", + source_language=source_language, + is_sentence=" " in story.location + ) + story.location = get_transliteration_output(result) + updated = True + + # ---------- TRANSLITERATE PERSONAL INFO IN OTHER_PARAMS ---------- + transliteration_fields = ["user_name", "location", "organization", "designation"] + for field in transliteration_fields: + val = other_params.get(field) + if val and is_non_english_text(val): + result = transliterate_text( + voice_provider=transliteration_provider, + message_body=val, + target_language="en", + source_language=source_language, + is_sentence=" " in val + ) + other_params[field] = get_transliteration_output(result) + updated = True + + # ---------- TRANSLATE DURATION ---------- + duration = other_params.get("duration") + if duration and is_non_english_text(duration): + other_params["duration"] = translate_field( + voice_provider=translation_provider, + message_body=duration, + target_language="en", + source_language=source_language + ) + updated = True + + # ---------- SAVE ---------- + if updated: + story.other_params = other_params + story.language = "en" + story.save(update_fields=[ + "title", "content", "objective", "impact", + "micro_improvement", "tweet", "blurb", "location", + "action_steps", "other_params", "language" + ]) + + fixed.append({ + "story_id": story.id, + "source_language": source_language, + "fields": offending_fields + }) + + # ---------- WRITE REPORT ---------- + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + f.write("GUEST MI STORY FIX REPORT\n") + f.write(f"Generated at: {datetime.utcnow().isoformat()} UTC\n") + f.write("=" * 70 + "\n\n") + + f.write(f"Total stories processed: {total}\n") + f.write(f"Fixed: {len(fixed)}\n") + f.write(f"Failed: {len(failed)}\n") + f.write(f"Skipped (already English): {len(skipped)}\n\n") + + if failed: + f.write("---- FAILED (Non-English with source_language=en) ----\n") + for item in failed: + f.write(f"Story ID: {item['story_id']}\n") + f.write(f"Reason: {item['reason']}\n") + f.write("Fields with non-English text:\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + if fixed: + f.write("---- FIXED ----\n") + for item in fixed: + f.write(f"Story ID: {item['story_id']} | source_language={item['source_language']}\n") + f.write("Fields that were fixed:\n") + for field in item["fields"]: + f.write(f" - {field}\n") + f.write("\n") + + if skipped: + f.write("---- SKIPPED (Already in English) ----\n") + f.write(f"Story IDs: {', '.join(map(str, skipped[:50]))}") + if len(skipped) > 50: + f.write(f"... and {len(skipped) - 50} more\n") + f.write("\n") + + print("=" * 70) + print("Guest MI Story Fix Completed") + print("=" * 70) + print(f"Total stories processed: {total}") + print(f"Fixed: {len(fixed)}") + print(f"Failed: {len(failed)}") + print(f"Skipped (already English): {len(skipped)}") + print(f"Report saved to: {OUTPUT_FILE}") + print("=" * 70) + + return { + "total": total, + "fixed": len(fixed), + "failed": len(failed), + "skipped": len(skipped), + "report_file": OUTPUT_FILE + } + diff --git a/chatbot/scripts/mi_guest_flow/post_processing/story_update_script.py b/chatbot/scripts/mi_guest_flow/post_processing/story_update_script.py new file mode 100644 index 0000000..7ddf608 --- /dev/null +++ b/chatbot/scripts/mi_guest_flow/post_processing/story_update_script.py @@ -0,0 +1,489 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed + +from django.utils.timezone import make_aware +from jinja2 import Template + +from chatbot.models import ( + Story, + ChatSession, + SessionFlowName, + CompanyBot, + Voice, + VoiceType, + CompanyChat, + BotVernacular, LLMProvider, +) +from chatbot.models.geo_models import ProfileAddress +from chatbot.utils.chat_utils import get_guided_chat +from chatbot.utils.sql_utils import get_todays_date +from chatbot.utils.story_utils.common.generic_story_tasks import ( + save_generic_story, + translate_to_english_if_needed +) +from chatbot.utils.story_utils.get_story_prompts import ( + get_tool_values, +) +from chatbot.utils.story_utils.story_llm import generate_story_llm + +logger = logging.getLogger("django") + +def get_creation_promt(company_bot, profile, session_id): + context = company_bot.context + address = ProfileAddress.objects.filter(profile=profile) + + state_machines = company_bot.companystatemachine_set.all().order_by('step') + master_question = None + + if state_machines.exists(): + first_state_machine = state_machines.first() + if first_state_machine.bot_question and first_state_machine.bot_question.strip(): + master_question = first_state_machine.bot_question.strip() + + story = Story.objects.filter(session=session_id).first() + if not story: + print("No story found!!!") + return {} + + context_data = { + "profile": profile, + "address": address if address else [{}], + "story": story + } + + if master_question: + context_data["master_question"] = master_question + + template = Template(company_bot.tag_context) + tag_context = template.render(context_data) + + end_context = company_bot.end_context + project_data = '' + today_date = get_todays_date(company_bot=company_bot) + + content_prompt = f""" + {context} + {tag_context} + {today_date} + {project_data} + """ if context else None + story_prompt = f""" + {end_context} + {tag_context} + {today_date} + {project_data} + """ if end_context else None + formatted_content_prompt = [] + formatted_story_prompt = [] + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + formatted_content_prompt = [ + { + 'text': content_prompt + }, + ] if content_prompt else None + formatted_story_prompt = [ + { + 'text': story_prompt + }, + ] if story_prompt else None + elif company_bot.provider == LLMProvider.OPENAI: + formatted_content_prompt = [ + { + 'role': 'system', + 'content': content_prompt + }, + ] if content_prompt else None + formatted_story_prompt = [ + { + 'role': 'system', + 'content': story_prompt + }, + ] if story_prompt else None + + return formatted_content_prompt, formatted_story_prompt, tag_context, project_data + + +def get_validation_prompt( + response_json_story, validate_bot, response_json_content, tag_context, project_data, profile, session_id +): + address = ProfileAddress.objects.filter(profile=profile) + + state_machines = validate_bot.companystatemachine_set.all().order_by('step') + master_question = None + + if state_machines.exists(): + first_state_machine = state_machines.first() + if first_state_machine.bot_question and first_state_machine.bot_question.strip(): + master_question = first_state_machine.bot_question.strip() + + + story = Story.objects.filter(session=session_id).first() + if not story: + print("No story found!!!") + return {} + + validate_context_data = { + "story_json_output": response_json_story, + "profile": profile, + "address": address if address else [{}], + "story": story, + } + + if master_question: + validate_context_data["master_question"] = master_question + + validate_template = Template(validate_bot.tag_context) + validate_tag_context = validate_template.render(validate_context_data) + + today_date = get_todays_date(company_bot=validate_bot) + + validate_story_prompt = f""" + {validate_bot.end_context} + {validate_tag_context} + {tag_context} + {today_date} + {project_data} + """ if validate_bot.end_context else None + + validate_context_data = { + "story_json_output": response_json_content, + "story": story, + "profile": profile, + "address": address if address else [{}] + } + + if master_question: + validate_context_data["master_question"] = master_question + + validate_tag_context = validate_template.render(validate_context_data) + + print("="*70) + print("response_json_content: ", response_json_content) + print("validate_tag_context: ", validate_tag_context) + print("="*70) + validate_content_prompt = f""" + {validate_bot.context} + {validate_tag_context} + {tag_context} + {today_date} + {project_data} + """ if validate_bot.context else None + + if validate_bot.provider == LLMProvider.BEDROCK_CONVERSE: + validate_content_prompt = [ + { + 'text': validate_content_prompt + }, + ] if validate_content_prompt else None + validate_story_prompt = [ + { + 'text': validate_story_prompt + }, + ] if validate_story_prompt else None + elif validate_bot.provider == LLMProvider.OPENAI: + validate_content_prompt = [ + { + 'role': 'system', + 'content': validate_content_prompt + }, + ] if validate_content_prompt else None + validate_story_prompt = [ + { + 'role': 'system', + 'content': validate_story_prompt + }, + ] if validate_story_prompt else None + + return validate_content_prompt, validate_story_prompt + +def resolve_date_range(start_date=None, end_date=None): + if start_date and end_date: + return make_aware(start_date), make_aware(end_date) + + yesterday = datetime.now() - timedelta(days=1) + start = make_aware(datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0)) + end = make_aware(datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59)) + return start, end + + +def get_sessions_from_story_flow(start_date=None, end_date=None): + start_time, end_time = resolve_date_range(start_date, end_date) + + session_ids = ( + Story.objects + .filter( + created_at__range=(start_time, end_time), + other_params__flow=SessionFlowName.GuestMiStory + ) + .values_list("session", flat=True) + ) + + sessions = ( + ChatSession.objects + .filter(session__in=session_ids) + .select_related("profile") + ) + + logger.info(f"Fetched {len(sessions)} sessions") + print(f"[INFO] Fetched {len(sessions)} sessions") + + return list(sessions) + + +def process_session(session, access_token): + try: + session_id = session.session + profile = session.profile + language = session.language or "en" + + logger.info(f"Processing session {session_id}") + print(f"[INFO] Processing session {session_id}") + + company_bot = CompanyBot.objects.get(route="/mi_story_update") + validate_bot = CompanyBot.objects.get(route="/mi_story_update_validate") + + voice_provider = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.TextToText, + language=language + ).first() + + company_chats = CompanyChat.objects.filter( + session=session_id + ).order_by("created_at") + + intro_to_pass = None + flow_bot = CompanyBot.objects.get(company=profile.company, route="/guided_guest") + bot_vernacular = BotVernacular.objects.filter(company_bot=flow_bot).first() + + if bot_vernacular: + intro_to_pass = ( + bot_vernacular.introductory_message + if access_token else + bot_vernacular.alt_introductory_message + ) + + messages = get_guided_chat( + company_bot=company_bot, + company_chats=company_chats, + intro=intro_to_pass + ) + + formatted_content_prompt, formatted_story_prompt, _, _ = get_creation_promt( + company_bot=company_bot, + profile=profile, + session_id=session_id + ) + + tool_content, tool_story = get_tool_values(company_bot=company_bot) + + logger.info(f"Calling primary LLM for session {session_id}") + print(f"[INFO] Calling primary LLM for session {session_id}") + + response_json_content, _ = asyncio.run( + generate_story_llm( + formatted_content_prompt=formatted_content_prompt, + formatted_story_prompt=formatted_story_prompt, + messages=messages, + tool_content=tool_content, + tool_story=tool_story, + company_bot=company_bot, + flow=SessionFlowName.GuestMiStory + ) + ) + + if not isinstance(response_json_content, dict): + logger.error(f"Initial LLM response is not JSON for session {session_id}") + print(f"[ERROR] Initial LLM response is not JSON for session {session_id}") + raise Exception("Initial LLM response is not JSON") + + validate_content_prompt, validate_story_prompt = get_validation_prompt( + response_json_story=response_json_content, + validate_bot=validate_bot, + response_json_content=response_json_content, + tag_context="", + project_data="", + profile=profile, + session_id=session_id + ) + + print("=" * 70) + print("validate_content_prompt:") + print(validate_content_prompt) + print("=" * 70) + + tool_content, tool_story = get_tool_values(company_bot=validate_bot) + + logger.info(f"Calling validation LLM for session {session_id}") + print(f"[INFO] Calling validation LLM for session {session_id}") + + validated_response, _ = asyncio.run( + generate_story_llm( + formatted_content_prompt=validate_content_prompt, + formatted_story_prompt=validate_story_prompt, + messages=messages, + tool_content=tool_content, + tool_story=tool_story, + company_bot=validate_bot, + flow=SessionFlowName.GuestMiStory + ) + ) + + if isinstance(validated_response, dict): + response_json_content = validated_response + else: + logger.error(f"Validation LLM returned invalid JSON for session {session_id}") + print(f"[ERROR] Validation LLM returned invalid JSON for session {session_id}") + raise Exception("Validation LLM failed") + + if "challenge" in response_json_content: + response_json_content.setdefault( + "problem_statement", + response_json_content.get("challenge") + ) + + response_json_content.pop("challenge", None) + + logger.info(f"Saving story for session {session_id}") + print(f"[INFO] Saving story for session {session_id}") + + save_generic_story( + response_json_story=response_json_content, + language=language, + voice_provider=voice_provider, + profile=profile, + session=session_id, + combined_reason="", + flow=SessionFlowName.GuestMiStory, + company_bot=company_bot, + exclude_fields=['problem_statement', 'user_name', 'title'] + ) + + if response_json_content.get("problem_statement"): + from shikshalokam.models import Project, ProjectVernacular + from chatbot.utils.story_utils.format_utils import clean_escaped_text + from chatbot.utils.story_llama_utils import translate_field + import json + + raw_problem = response_json_content.get("problem_statement", "") + raw_title = response_json_content.get("title", "") + + english_problem = clean_escaped_text( + translate_to_english_if_needed(raw_problem, voice_provider, language) + ) + english_title = clean_escaped_text( + translate_to_english_if_needed(raw_title, voice_provider, language) + ) + + story = Story.objects.filter(session=session_id).first() + if not story: + return session_id, True + + project = Project.objects.filter(story=story).first() + if not project: + return session_id, True + + project.actual_problem_statement = english_problem + project.actual_title = english_title + project.save(update_fields=["actual_problem_statement", "actual_title"]) + + if language != "en": + translated_problem = translate_field( + voice_provider, english_problem, language, "en" + ) + translated_title = translate_field( + voice_provider, english_title, language, "en" + ) + + project_vernacular, _ = ProjectVernacular.objects.get_or_create( + project=project, + language=language, + defaults={"details": "{}"} + ) + + details = json.loads(project_vernacular.details or "{}") + details.setdefault("project", {}) + details["project"].update({ + "actual_problem_statement": translated_problem, + "actual_title": translated_title + }) + + project_vernacular.details = json.dumps(details) + project_vernacular.save(update_fields=["details"]) + + logger.info(f"Session {session_id} processed successfully") + print(f"[INFO] Session {session_id} processed successfully") + + return session_id, True + + except Exception as e: + logger.error(f"Failed for session {session.session}: {str(e)}", exc_info=True) + print(f"[ERROR] Failed for session {session.session}: {str(e)}") + return session.session, False + + +def create_stories_parallel(sessions, access_token, max_workers=4): + succeeded = [] + failed = [] + + logger.info(f"Running with ThreadPoolExecutor (workers={max_workers})") + print(f"[INFO] Running with ThreadPoolExecutor (workers={max_workers})") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(process_session, session, access_token) + for session in sessions + ] + + for future in as_completed(futures): + session_id, success = future.result() + if success: + succeeded.append(session_id) + else: + failed.append(session_id) + + logger.info(f"Success count: {len(succeeded)}") + logger.info(f"Failure count: {len(failed)}") + print(f"[INFO] Success count: {len(succeeded)}") + print(f"[INFO] Failure count: {len(failed)}") + + return succeeded, failed + + +def run_story_update_from_temp_bot( + access_token, + start_date=None, + end_date=None, + max_workers=4, + session_ids=None +): + logger.info("Starting story update pipeline") + print("[INFO] Starting story update pipeline") + + if session_ids: + sessions = ( + ChatSession.objects + .filter(session__in=session_ids) + .select_related("profile") + ) + else: + sessions = get_sessions_from_story_flow(start_date, end_date) + + if not sessions: + logger.info("No sessions found to process") + print("[INFO] No sessions found to process") + return [], [] + + succeeded, failed = create_stories_parallel( + sessions=sessions, + access_token=access_token, + max_workers=max_workers + ) + + logger.info(f"Pipeline completed. Success: {len(succeeded)}, Failed: {len(failed)}") + print(f"[INFO] Pipeline completed. Success: {len(succeeded)}, Failed: {len(failed)}") + + return succeeded, failed diff --git a/chatbot/scripts/mi_guest_flow/translate_script.py b/chatbot/scripts/mi_guest_flow/translate_script.py new file mode 100644 index 0000000..644fc26 --- /dev/null +++ b/chatbot/scripts/mi_guest_flow/translate_script.py @@ -0,0 +1,197 @@ +from chatbot.models import Story, Voice, VoiceType, CompanyBot, ChatSession, ChatStatus +from django.db import transaction +import json +from django.db.models import Q +from chatbot.utils.audio_provider_utils import text_translate_provider +from django.utils.timezone import make_aware +from datetime import datetime +import json +from django.db import transaction +from json_repair import repair_json + +from chatbot.utils.transliterate_utils import transliterate_text + + +###Steps To Follow: + #First step is to call get_story_count() for given bots (Adjust the date as needed) + #Second step is to call translate_specific_story_ids() and pass the story_ids we collected in First Step to + #translate stories and save english version + + +def translate_field(voice_provider, message_body, target_language, source_language="en"): + if not message_body or message_body == '': + return message_body + response = text_translate_provider( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language + ) + if response.get('status') == 200: + return response.get('content') + else: + return message_body + +def transliterate_field(voice_provider, message_body, target_language, source_language="en", is_sentence=False): + if not message_body or message_body == '': + return message_body + response = transliterate_text( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language, is_sentence=is_sentence + ) + if response.get('status') == 200: + return response.get('content') + else: + return message_body + +def process_story(story): + try: + other_params = story.other_params or {} + + if 'english_json' in other_params: + return f"Story ID {story.id} already translated." + + company_bot = CompanyBot.objects.get(route='/guest-story') + voice_provider = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.TextToText + ).first() + voice_provider_transliterate = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.Transliterate + ).first() + + if not voice_provider: + return f"No voice provider for Story ID {story.id} with language {story.language}" + + raw_data = { + "title": story.title or "", + "content": story.content or "", + "blurb": story.blurb or "", + "tweet": story.tweet or "", + "objective": story.objective or "", + "action_steps": story.action_steps or "", + "impact": story.impact or "", + "micro_improvement": story.micro_improvement or "", + "user_name": other_params.get("user_name", ""), + "designation": other_params.get("designation", ""), + "organization": other_params.get("organization", "") + } + + translated_data = {} + + for key, value in raw_data.items(): + if key == "action_steps": + translated_data[key] = translate_action_steps( + raw_value=value, voice_provider=voice_provider, source_language=story.language + ) + elif key in ["user_name", "organization"]: + word_count = len(value.strip().split()) + is_sentence = word_count > 1 + translated_data[key] = transliterate_field( + voice_provider=voice_provider_transliterate, message_body=value, + target_language='en', source_language=story.language, is_sentence=is_sentence + ) if value else "" + else: + if voice_provider_transliterate and value: + translated_data[key] = translate_field( + voice_provider=voice_provider, message_body=value, target_language='en', + source_language=story.language + ) + else: + translated_data[key] = value + + other_params["english_json"] = translated_data + + with transaction.atomic(): + story.other_params = other_params + story.save(update_fields=["other_params"]) + + return f"✅ Translated & updated Story ID {story.id}" + + except Exception as e: + return f"❌ Error processing Story ID {story.id}: {str(e)}" + + +def translate_action_steps(raw_value, voice_provider, source_language): + """ + Handles action_steps: can be string or list. + """ + if not raw_value: + return [] + + # Try to parse JSON array + parsed = None + + if isinstance(raw_value, str): + try: + parsed = json.loads(raw_value) + except: + try: + parsed = json.loads(repair_json(raw_value)) + except: + parsed = None + + if isinstance(parsed, list): + # Confirm all elements are strings + translated_steps = [] + for step in parsed: + translated = translate_field(voice_provider, step, 'en', source_language) if step else "" + translated_steps.append(translated) + return translated_steps + + elif isinstance(raw_value, list): + # Already a Python list + translated_steps = [] + for step in raw_value: + translated = translate_field(voice_provider, step, 'en', source_language) if step else "" + translated_steps.append(translated) + return translated_steps + + elif isinstance(raw_value, str): + # Fallback: translate as one big string + return translate_field(voice_provider, raw_value, 'en', source_language) + + else: + # Unknown format + return raw_value + + +def get_story_count(): + start_time = make_aware(datetime(2025, 6, 1, 0, 0)) + end_time = make_aware(datetime(2025, 7, 10, 23, 59, 59)) + bots = CompanyBot.objects.filter(route__in=["/oneshot_guest", "/guided_guest"]) + + session_ids = list( + ChatSession.objects.filter( + created_at__gt=start_time, + created_at__lt=end_time, + company_bot__in=bots, + session_status=ChatStatus.COMPLETED + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + print("session ids: ", session_ids) + if session_ids: + print("First session id: ", session_ids[0]) + print("Last session id: ", session_ids[-1]) + else: + print("No sessions found.") + + story_ids = list( + Story.objects.filter(session__in=session_ids) + .exclude(Q(other_params=None) | Q(language='en')) + .order_by('-id') + .values_list('id', flat=True) + ) + + print(f"Total stories: {len(story_ids)}") + return story_ids + + +def translate_specific_story_ids(story_ids): + stories = Story.objects.filter(id__in=story_ids) + + print(f"Translating specific stories: {story_ids}... Total: {stories.count()}") + + for story in stories: + print(process_story(story)) \ No newline at end of file diff --git a/chatbot/scripts/parent_perception/location_script.py b/chatbot/scripts/parent_perception/location_script.py new file mode 100644 index 0000000..e8f333c --- /dev/null +++ b/chatbot/scripts/parent_perception/location_script.py @@ -0,0 +1,149 @@ +import json +import os +import sys +import django + +# Setup Django if running directly (not in Django shell) +if __name__ == "__main__": + try: + # Check if Django is already configured (running in shell) + django.apps.apps.check_apps_ready() + except Exception: + # Add project root to path + try: + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + except NameError: + # If __file__ is not defined (pasted in shell), use current directory + project_root = os.getcwd() + + sys.path.insert(0, project_root) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + django.setup() + +from chatbot.models import ChatSession, ChatType, CompanyChat + + +def get_target_sessions(session_id=None): + qs = ChatSession.objects.filter( + session_type=ChatType.ParentPerceptionSurvey + ) + + if not session_id: + return qs + + if isinstance(session_id, list): + return qs.filter(session__in=session_id) + + return qs.filter(session=session_id) + + +def get_chat_text(chat): + if chat.translated_message: + return chat.translated_message.strip() + + if chat.message: + return chat.message.strip() + + return "" + + +def extract_location_from_chats(chat_session): + chats = CompanyChat.objects.filter( + session=chat_session.session, + receiver=1 + ).order_by("created_at")[:2] + + state = "" + district = "" + + for chat in chats: + text = get_chat_text(chat) + if not text: + continue + + if not state: + state = text.lower() + continue + + if not district: + district = text.lower() + + if state and district: + return f"{state} {district}" + + return "" + + +def extract_ip_location(chat_session): + other_params = chat_session.other_params or {} + ip_data = other_params.get("ip_address", {}) + + ip_city = ip_data.get("ipCity", "") + ip_state = ip_data.get("ipState", "") + + parts = [p for p in [ip_state, ip_city] if p] + return " ".join(parts) + + +def build_location_response(chat_session): + return { + "session_id": chat_session.session, + "user_chat_location": extract_location_from_chats(chat_session), + "ip_location": extract_ip_location(chat_session), + } + + +def get_parent_perception_location_metadata(session_id=None): + sessions = get_target_sessions(session_id) + + results = [] + for chat_session in sessions: + results.append(build_location_response(chat_session)) + + return results + + +def save_to_json_file(session_id=None): + """ + Save location metadata to a static JSON file in current directory + """ + results = get_parent_perception_location_metadata(session_id) + + # Use static filename in current directory + file_path = "parent_perception_locations.json" + + # Save to file + with open(file_path, "w") as f: + json.dump(results, f, indent=2) + + print(f"✓ Saved {len(results)} location records to: {os.path.abspath(file_path)}") + return os.path.abspath(file_path) + + +def run(): + """ + Run the script - prints to terminal and saves to parent_perception_locations.json + """ + results = get_parent_perception_location_metadata() + + # Print to terminal + print("\n" + "="*60) + print("PARENT PERCEPTION LOCATION METADATA") + print("="*60) + print(json.dumps(results, indent=2)) + print("="*60) + print(f"Total records: {len(results)}\n") + + # Save to file + file_path = save_to_json_file() + + return { + "results": results, + "file_path": file_path + } + + +# Allow running directly +if __name__ == "__main__": + run() + diff --git a/chatbot/scripts/qdrant_sanity_check.py b/chatbot/scripts/qdrant_sanity_check.py new file mode 100644 index 0000000..7afb376 --- /dev/null +++ b/chatbot/scripts/qdrant_sanity_check.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Qdrant Sanity Check Script +Validates collection structure, vector dimensions, and metadata completeness +""" + +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, VectorParams +from collections import defaultdict +import json + +# Configuration +QDRANT_HOST = "localhost" +QDRANT_PORT = 6333 +COLLECTION_NAME = "documents" # Only check this collection +EXPECTED_VECTORS = ["title", "text", "metadata", "tags", "summary"] +EXPECTED_DIMENSION = 384 + +def connect_to_qdrant(): + """Connect to local Qdrant instance""" + try: + client = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT) + print(f"✓ Connected to Qdrant at {QDRANT_HOST}:{QDRANT_PORT}") + return client + except Exception as e: + print(f"✗ Failed to connect to Qdrant: {e}") + return None + +def get_collections_info(client): + """Get all collections and their basic info""" + try: + collections = client.get_collections().collections + print(f"\n📊 Found {len(collections)} collection(s)") + return collections + except Exception as e: + print(f"✗ Error fetching collections: {e}") + return [] + +def check_collection(client, collection_name): + """Perform detailed checks on a collection""" + print(f"\n{'='*80}") + print(f"🔍 Checking Collection: {collection_name}") + print(f"{'='*80}") + + # Get collection info + try: + collection_info = client.get_collection(collection_name) + print(f"\n📈 Collection Stats:") + print(f" Points count: {collection_info.points_count}") + print(f" Vectors count: {collection_info.vectors_count}") + + # Check vector configuration + print(f"\n🔧 Vector Configuration:") + vectors_config = collection_info.config.params.vectors + + if isinstance(vectors_config, dict): + for vector_name, config in vectors_config.items(): + print(f" • {vector_name}: {config.size} dimensions, {config.distance}") + else: + print(f" Single vector: {vectors_config.size} dimensions") + + except Exception as e: + print(f"✗ Error getting collection info: {e}") + return + + # Fetch all points + print(f"\n📥 Fetching all points...") + try: + points = client.scroll( + collection_name=collection_name, + limit=10000, # Adjust based on your data size + with_payload=True, + with_vectors=True + )[0] + + print(f" Retrieved {len(points)} points") + + except Exception as e: + print(f"✗ Error fetching points: {e}") + return + + # Initialize counters + issues = { + "missing_vectors": defaultdict(list), + "wrong_dimensions": defaultdict(list), + "missing_metadata": defaultdict(list), + "null_tags": [] + } + + valid_points = 0 + + # Check each point + print(f"\n🔎 Validating points...") + for idx, point in enumerate(points): + point_id = point.id + has_issues = False + + # Check vectors + if hasattr(point, 'vector') and point.vector: + vectors = point.vector if isinstance(point.vector, dict) else {"default": point.vector} + + # Check for missing vectors + for expected_vector in EXPECTED_VECTORS: + if expected_vector not in vectors: + issues["missing_vectors"][expected_vector].append(point_id) + has_issues = True + else: + # Check dimensions + vector_data = vectors[expected_vector] + if len(vector_data) != EXPECTED_DIMENSION: + issues["wrong_dimensions"][expected_vector].append( + (point_id, len(vector_data)) + ) + has_issues = True + else: + for expected_vector in EXPECTED_VECTORS: + issues["missing_vectors"][expected_vector].append(point_id) + has_issues = True + + # Check payload/metadata + if hasattr(point, 'payload') and point.payload: + payload = point.payload + + # Check for null or empty tags + if "tags" in payload: + tags = payload.get("tags") + if tags is None or tags == "" or (isinstance(tags, list) and len(tags) == 0): + issues["null_tags"].append(point_id) + has_issues = True + else: + issues["missing_metadata"]["tags"].append(point_id) + has_issues = True + + # Check for other important metadata fields + important_fields = ["title", "text", "metadata", "summary"] + for field in important_fields: + if field not in payload or payload[field] is None or payload[field] == "": + issues["missing_metadata"][field].append(point_id) + has_issues = True + else: + issues["missing_metadata"]["payload"].append(point_id) + has_issues = True + + if not has_issues: + valid_points += 1 + + # Progress indicator + if (idx + 1) % 100 == 0: + print(f" Processed {idx + 1}/{len(points)} points...", end="\r") + + print(f" Processed {len(points)}/{len(points)} points... ") + + # Print summary + print(f"\n{'='*80}") + print(f"📋 VALIDATION SUMMARY") + print(f"{'='*80}") + print(f"\n✓ Valid points: {valid_points}/{len(points)} ({valid_points/len(points)*100:.2f}%)") + print(f"✗ Points with issues: {len(points) - valid_points}/{len(points)} ({(len(points) - valid_points)/len(points)*100:.2f}%)") + + # Detailed issues report + if any(issues.values()): + print(f"\n🚨 ISSUES FOUND:") + + # Missing vectors + if issues["missing_vectors"]: + print(f"\n Missing Vectors:") + for vector_name, point_ids in issues["missing_vectors"].items(): + print(f" • {vector_name}: {len(point_ids)} points") + if len(point_ids) <= 5: + print(f" Point IDs: {point_ids}") + else: + print(f" Point IDs (first 5): {point_ids[:5]}") + + # Wrong dimensions + if issues["wrong_dimensions"]: + print(f"\n Wrong Vector Dimensions (expected {EXPECTED_DIMENSION}):") + for vector_name, errors in issues["wrong_dimensions"].items(): + print(f" • {vector_name}: {len(errors)} points") + if len(errors) <= 3: + for point_id, dim in errors: + print(f" Point {point_id}: {dim} dimensions") + else: + for point_id, dim in errors[:3]: + print(f" Point {point_id}: {dim} dimensions") + print(f" ... and {len(errors) - 3} more") + + # Missing metadata + if issues["missing_metadata"]: + print(f"\n Missing/Empty Metadata:") + for field_name, point_ids in issues["missing_metadata"].items(): + print(f" • {field_name}: {len(point_ids)} points") + if len(point_ids) <= 5: + print(f" Point IDs: {point_ids}") + else: + print(f" Point IDs (first 5): {point_ids[:5]}") + + # Null tags + if issues["null_tags"]: + print(f"\n Null/Empty Tags:") + print(f" • {len(issues['null_tags'])} points with null/empty tags") + if len(issues["null_tags"]) <= 5: + print(f" Point IDs: {issues['null_tags']}") + else: + print(f" Point IDs (first 5): {issues['null_tags'][:5]}") + else: + print(f"\n✓ No issues found! All points are valid.") + + # Save detailed report to file + report_file = f"qdrant_report_{collection_name}.json" + report = { + "collection_name": collection_name, + "total_points": len(points), + "valid_points": valid_points, + "invalid_points": len(points) - valid_points, + "issues": { + "missing_vectors": {k: len(v) for k, v in issues["missing_vectors"].items()}, + "wrong_dimensions": {k: len(v) for k, v in issues["wrong_dimensions"].items()}, + "missing_metadata": {k: len(v) for k, v in issues["missing_metadata"].items()}, + "null_tags_count": len(issues["null_tags"]) + }, + "detailed_issues": { + "missing_vectors": {k: v for k, v in issues["missing_vectors"].items()}, + "wrong_dimensions": {k: [(str(pid), dim) for pid, dim in v] for k, v in issues["wrong_dimensions"].items()}, + "missing_metadata": {k: v for k, v in issues["missing_metadata"].items()}, + "null_tags": issues["null_tags"] + } + } + + with open(report_file, 'w') as f: + json.dump(report, f, indent=2) + print(f"\n💾 Detailed report saved to: {report_file}") + +def main(): + """Main execution function""" + print("🚀 Starting Qdrant Sanity Check") + print(f"{'='*80}\n") + + # Connect to Qdrant + client = connect_to_qdrant() + if not client: + return + + # Check if documents collection exists + try: + collections = client.get_collections().collections + collection_names = [c.name for c in collections] + + if COLLECTION_NAME not in collection_names: + print(f"\n⚠️ Collection '{COLLECTION_NAME}' not found!") + print(f"Available collections: {', '.join(collection_names)}") + return + + print(f"\n✓ Found collection: {COLLECTION_NAME}") + + except Exception as e: + print(f"✗ Error checking collections: {e}") + return + + # Check the documents collection + check_collection(client, COLLECTION_NAME) + + print(f"\n{'='*80}") + print("✅ Sanity check completed!") + print(f"{'='*80}\n") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/chatbot/scripts/sync_media_to_vector_db.py b/chatbot/scripts/sync_media_to_vector_db.py new file mode 100644 index 0000000..e41df7c --- /dev/null +++ b/chatbot/scripts/sync_media_to_vector_db.py @@ -0,0 +1,463 @@ +import os +import sys +import argparse +import traceback +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +import time + +# Django setup +import django +# if __name__ == '__main__': +# project_root = Path(__file__).resolve().parent.parent.parent +# sys.path.insert(0, str(project_root)) +# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') +# django.setup() + +from django.db.models import Q +from chatbot.models import Media, Company, CompanyBot +from chatbot.celery_tasks.knowledge_service.media_tasks import prepare_vector_db_data +from chatbot.utils.database_util import upsert_single_file + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('sync_media_to_vector_db.log') + ] +) +logger = logging.getLogger(__name__) + + +class MediaVectorDBSync: + """Sync media files from database to vector database""" + + def __init__( + self, + company_slug: Optional[str] = None, + company_bot_id: Optional[int] = None, + media_ids: Optional[List[int]] = None, + batch_size: int = 10, + dry_run: bool = False, + skip_errors: bool = True, + sleep_time: float = 2.0, + max_retries: int = 3, + retry_delay: float = 5.0 + ): + """ + Initialize sync manager + + Args: + company_slug: Filter by company slug + company_bot_id: Filter by company bot ID + media_ids: Specific media IDs to process + batch_size: Number of media to process in each batch + dry_run: Test mode (don't actually upsert) + skip_errors: Continue processing if errors occur + sleep_time: Time to sleep between processing each document (seconds) + max_retries: Maximum number of retries for failed embeddings + retry_delay: Delay between retries (seconds) + """ + self.company_slug = company_slug + self.company_bot_id = company_bot_id + self.media_ids = media_ids + self.batch_size = batch_size + self.dry_run = dry_run + self.skip_errors = skip_errors + self.sleep_time = sleep_time + self.max_retries = max_retries + self.retry_delay = retry_delay + + self.stats = { + 'total_media': 0, + 'processed': 0, + 'successful': 0, + 'failed': 0, + 'skipped': 0, + 'retried': 0 + } + + self.results = [] + self._validate_filters() + + def _validate_filters(self): + """Validate company and bot filters""" + if self.company_slug: + try: + self.company = Company.objects.get(slug=self.company_slug) + logger.info(f"Found company: {self.company.name} ({self.company_slug})") + print(f"✅ Found company: {self.company.name} ({self.company_slug})") + except Company.DoesNotExist: + logger.error(f"Company with slug '{self.company_slug}' not found") + raise ValueError(f"Company with slug '{self.company_slug}' not found") + + if self.company_bot_id: + try: + self.company_bot = CompanyBot.objects.get(id=self.company_bot_id) + logger.info(f"Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + print(f"✅ Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + except CompanyBot.DoesNotExist: + logger.error(f"CompanyBot with ID {self.company_bot_id} not found") + raise ValueError(f"CompanyBot with ID {self.company_bot_id} not found") + + def get_media_queryset(self): + """Get filtered media queryset""" + queryset = Media.objects.all() + + # Apply filters + if self.media_ids: + queryset = queryset.filter(id__in=self.media_ids) + print(f"🔍 Filter: Specific media IDs: {self.media_ids}") + + if self.company_slug: + queryset = queryset.filter( + Q(company_bot__company__slug=self.company_slug) | + Q(organization__slug=self.company_slug) + ) + print(f"🔍 Filter: Company slug = {self.company_slug}") + + if self.company_bot_id: + queryset = queryset.filter(company_bot_id=self.company_bot_id) + print(f"🔍 Filter: Bot ID = {self.company_bot_id}") + + # Order by ID for consistent processing + queryset = queryset.order_by('id') + + self.stats['total_media'] = queryset.count() + print(f"📊 Total media to process: {self.stats['total_media']}\n") + + return queryset + + def sync_single_media(self, media_id: int, retry_count: int = 0) -> Dict[str, Any]: + """ + Sync a single media file to vector DB with retry mechanism + + Args: + media_id: Media ID to process + retry_count: Current retry attempt number + + Returns: + Result dictionary with success status and details + """ + try: + logger.info(f"Processing media ID {media_id} (attempt {retry_count + 1}/{self.max_retries})") + + # Prepare vector DB data using existing function + media, file_name, file_content, metadata = prepare_vector_db_data( + media_id=media_id, + company_slug=self.company_slug + ) + + logger.debug(f"Prepared data for media ID {media_id}: file={file_name}, size={len(file_content)} bytes") + + if self.dry_run: + logger.info(f"DRY RUN: Would upsert media ID {media_id}") + print(f" 🔍 DRY RUN: Would upsert media ID {media_id}") + print(f" File: {file_name}") + print(f" Company: {metadata.get('company', 'N/A')}") + print(f" Tags: {len(metadata.get('tags', []))} tags") + + return { + 'success': True, + 'media_id': media_id, + 'file_name': file_name, + 'message': 'Dry run - not saved', + 'dry_run': True + } + + # Upsert to vector DB + logger.info(f"Upserting media ID {media_id} to vector DB") + status_code, response_text = upsert_single_file( + filename=file_name, + file=file_content, + metadata=metadata, + media=media + ) + + # Check if successful (2xx status codes) + success = 200 <= status_code < 300 + + if success: + logger.info(f"Successfully upserted media ID {media_id} with status {status_code}") + print(f" ✅ Successfully upserted media ID {media_id}") + return { + 'success': True, + 'media_id': media_id, + 'file_name': file_name, + 'status_code': status_code, + 'message': 'Successfully upserted', + 'retry_count': retry_count + } + else: + logger.error(f"Failed to upsert media ID {media_id}: Status {status_code}, Response: {response_text}") + + # Retry if not at max retries + if retry_count < self.max_retries - 1: + logger.warning(f"Retrying media ID {media_id} after {self.retry_delay} seconds...") + print(f" ⚠️ Retry {retry_count + 1}/{self.max_retries - 1} for media ID {media_id} after {self.retry_delay}s") + time.sleep(self.retry_delay) + self.stats['retried'] += 1 + return self.sync_single_media(media_id, retry_count + 1) + + print(f" ❌ Failed to upsert media ID {media_id}: Status {status_code}") + return { + 'success': False, + 'media_id': media_id, + 'file_name': file_name, + 'status_code': status_code, + 'message': f'Upsert failed with status {status_code} after {retry_count + 1} attempts', + 'response': response_text, + 'retry_count': retry_count + } + + except Media.DoesNotExist: + error_msg = f"Media with ID {media_id} not found" + logger.error(error_msg) + print(f" ⚠️ {error_msg}") + return { + 'success': False, + 'media_id': media_id, + 'message': error_msg, + 'error_type': 'MEDIA_NOT_FOUND', + 'retry_count': retry_count + } + + except Exception as e: + error_msg = str(e) + logger.exception(f"Error processing media ID {media_id}: {error_msg}") + print(f" ❌ Error processing media ID {media_id}: {error_msg}") + + if not self.skip_errors: + traceback.print_exc() + + # Retry on exception if not at max retries + if retry_count < self.max_retries - 1: + logger.warning(f"Retrying media ID {media_id} after exception, waiting {self.retry_delay} seconds...") + print(f" ⚠️ Retry {retry_count + 1}/{self.max_retries - 1} for media ID {media_id} after error") + time.sleep(self.retry_delay) + self.stats['retried'] += 1 + return self.sync_single_media(media_id, retry_count + 1) + + return { + 'success': False, + 'media_id': media_id, + 'message': error_msg, + 'error_type': 'PROCESSING_ERROR', + 'retry_count': retry_count + } + + def sync_all(self) -> Dict[str, Any]: + """ + Sync all filtered media to vector DB + + Returns: + Statistics dictionary + """ + media_queryset = self.get_media_queryset() + + if self.stats['total_media'] == 0: + logger.warning("No media found to process") + print("⚠️ No media found to process!") + return self.stats + + logger.info(f"Starting Vector DB Sync - Mode: {'DRY RUN' if self.dry_run else 'LIVE'}, Batch size: {self.batch_size}, Sleep time: {self.sleep_time}s") + print(f"{'='*80}") + print(f"Starting Vector DB Sync") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Batch size: {self.batch_size}") + print(f"Sleep time: {self.sleep_time}s between documents") + print(f"Max retries: {self.max_retries}") + print(f"{'='*80}\n") + + # Process in batches + media_ids = list(media_queryset.values_list('id', flat=True)) + + for i in range(0, len(media_ids), self.batch_size): + batch = media_ids[i:i + self.batch_size] + batch_num = (i // self.batch_size) + 1 + total_batches = (len(media_ids) + self.batch_size - 1) // self.batch_size + + logger.info(f"Processing batch {batch_num}/{total_batches} (Media IDs: {batch[0]} - {batch[-1]})") + print(f"\n{'='*80}") + print(f"Batch {batch_num}/{total_batches} (Media IDs: {batch[0]} - {batch[-1]})") + print(f"{'='*80}") + + for media_id in batch: + self.stats['processed'] += 1 + progress = f"[{self.stats['processed']}/{self.stats['total_media']}]" + + print(f"\n{progress} Processing Media ID: {media_id}") + print(f"{'-'*80}") + + result = self.sync_single_media(media_id) + self.results.append(result) + + if result['success']: + self.stats['successful'] += 1 + else: + self.stats['failed'] += 1 + + # Stop if not skipping errors + if not self.skip_errors: + logger.error("Stopping due to error (skip_errors=False)") + print(f"\n❌ Stopping due to error (skip_errors=False)") + self._print_summary() + return self.stats + + # Sleep between documents to avoid overwhelming the vector DB and ensure proper embedding generation + if not self.dry_run and self.stats['processed'] < self.stats['total_media']: + logger.debug(f"Sleeping for {self.sleep_time} seconds before next document") + print(f" ⏳ Waiting {self.sleep_time}s before next document...") + time.sleep(self.sleep_time) + + self._print_summary() + return self.stats + + def _print_summary(self): + """Print sync summary""" + logger.info(f"Sync Summary - Total: {self.stats['total_media']}, Successful: {self.stats['successful']}, Failed: {self.stats['failed']}, Retried: {self.stats['retried']}") + print(f"\n{'='*80}") + print(f"SYNC SUMMARY") + print(f"{'='*80}") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Total Media: {self.stats['total_media']}") + print(f"Processed: {self.stats['processed']}") + print(f"Successful: {self.stats['successful']}") + print(f"Failed: {self.stats['failed']}") + print(f"Skipped: {self.stats['skipped']}") + print(f"Retried: {self.stats['retried']}") + print(f"{'='*80}\n") + + if self.stats['failed'] > 0: + logger.error(f"Failed media count: {self.stats['failed']}") + print("Failed Media:") + for result in self.results: + if not result['success']: + media_id = result.get('media_id', 'Unknown') + message = result.get('message', 'Unknown error') + retry_count = result.get('retry_count', 0) + logger.error(f"Failed media ID {media_id}: {message} (retries: {retry_count})") + print(f" ❌ Media ID {media_id}: {message} (retries: {retry_count})") + print() + + +def main(): + """Main entry point for command-line usage""" + parser = argparse.ArgumentParser( + description='Sync media files from database to vector database' + ) + + parser.add_argument( + '--company-slug', + help='Filter by company slug' + ) + parser.add_argument( + '--bot-id', + type=int, + help='Filter by CompanyBot ID' + ) + parser.add_argument( + '--media-ids', + help='Comma-separated list of specific media IDs to process (e.g., 1,2,3)' + ) + parser.add_argument( + '--batch-size', + type=int, + default=10, + help='Number of media to process in each batch (default: 10)' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Test mode - show what would be done without actually upserting' + ) + parser.add_argument( + '--stop-on-error', + action='store_true', + help='Stop processing if an error occurs (default: continue)' + ) + parser.add_argument( + '--sleep-time', + type=float, + default=2.0, + help='Time to sleep between processing each document in seconds (default: 2.0)' + ) + parser.add_argument( + '--max-retries', + type=int, + default=3, + help='Maximum number of retries for failed embeddings (default: 3)' + ) + parser.add_argument( + '--retry-delay', + type=float, + default=5.0, + help='Delay between retries in seconds (default: 5.0)' + ) + + args = parser.parse_args() + + # Parse media IDs if provided + media_ids = None + if args.media_ids: + try: + media_ids = [int(x.strip()) for x in args.media_ids.split(',')] + except ValueError: + print("❌ Error: Invalid media IDs format. Use comma-separated integers (e.g., 1,2,3)") + sys.exit(1) + + try: + logger.info("Starting MediaVectorDBSync") + syncer = MediaVectorDBSync( + company_slug=args.company_slug, + company_bot_id=args.bot_id, + media_ids=media_ids, + batch_size=args.batch_size, + dry_run=args.dry_run, + skip_errors=not args.stop_on_error, + sleep_time=args.sleep_time, + max_retries=args.max_retries, + retry_delay=args.retry_delay + ) + + stats = syncer.sync_all() + + # Exit with error code if any failed + exit_code = 0 if stats['failed'] == 0 else 1 + logger.info(f"Sync completed with exit code {exit_code}") + sys.exit(exit_code) + + except Exception as e: + logger.exception(f"Fatal error: {e}") + print(f"\n❌ ERROR: {e}") + traceback.print_exc() + sys.exit(1) + + +# if __name__ == '__main__': +# main() + +# To use on all the media: + +# syncer = MediaVectorDBSync( +# dry_run=False, +# batch_size=10, +# sleep_time=2.0, +# max_retries=3, +# skip_errors=True +# ) + +# Run on specific media IDs +syncer = MediaVectorDBSync( + media_ids=[312, 315, 316, 626], + batch_size=10, + dry_run=False, + skip_errors=True, + sleep_time=2.0, + max_retries=3 +) + +stats = syncer.sync_all() \ No newline at end of file diff --git a/chatbot/scripts/sync_media_to_vector_db_celery.py b/chatbot/scripts/sync_media_to_vector_db_celery.py new file mode 100644 index 0000000..304dd02 --- /dev/null +++ b/chatbot/scripts/sync_media_to_vector_db_celery.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Sync media files to vector database using Celery tasks (same as extraction logic). + +This version uses the same Celery task approach as the normal extraction flow, +which avoids nginx size limits and handles large files better. +""" + +import os +import sys +import argparse +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +import time + +# Django setup +import django +if __name__ == '__main__': + project_root = Path(__file__).resolve().parent.parent.parent + sys.path.insert(0, str(project_root)) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + django.setup() + +from django.db.models import Q +from chatbot.models import Media, Company, CompanyBot +from chatbot.celery_tasks.knowledge_service.media_tasks import save_in_vector_db, update_in_vector_db +from celery.result import AsyncResult + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('sync_media_to_vector_db_celery.log') + ] +) +logger = logging.getLogger(__name__) + + +class MediaVectorDBSyncCelery: + """Sync media files using Celery tasks (same as extraction logic)""" + + def __init__( + self, + company_slug: Optional[str] = None, + company_bot_id: Optional[int] = None, + media_ids: Optional[List[int]] = None, + batch_size: int = 10, + dry_run: bool = False, + skip_errors: bool = True, + task_timeout: int = 300, + poll_interval: float = 2.0, + update_mode: bool = False + ): + """ + Initialize sync manager using Celery tasks + + Args: + company_slug: Filter by company slug + company_bot_id: Filter by company bot ID + media_ids: Specific media IDs to process + batch_size: Number of media to process in each batch + dry_run: Test mode (don't actually upsert) + skip_errors: Continue processing if errors occur + task_timeout: Maximum time to wait for each Celery task (seconds) + poll_interval: How often to check task status (seconds) + update_mode: Use update instead of upsert + """ + self.company_slug = company_slug + self.company_bot_id = company_bot_id + self.media_ids = media_ids + self.batch_size = batch_size + self.dry_run = dry_run + self.skip_errors = skip_errors + self.task_timeout = task_timeout + self.poll_interval = poll_interval + self.update_mode = update_mode + + self.stats = { + 'total_media': 0, + 'processed': 0, + 'successful': 0, + 'failed': 0, + 'timeout': 0 + } + + self.results = [] + self._validate_filters() + + def _validate_filters(self): + """Validate company and bot filters""" + if self.company_slug: + try: + self.company = Company.objects.get(slug=self.company_slug) + logger.info(f"Found company: {self.company.name} ({self.company_slug})") + print(f"✅ Found company: {self.company.name} ({self.company_slug})") + except Company.DoesNotExist: + logger.error(f"Company with slug '{self.company_slug}' not found") + raise ValueError(f"Company with slug '{self.company_slug}' not found") + + if self.company_bot_id: + try: + self.company_bot = CompanyBot.objects.get(id=self.company_bot_id) + logger.info(f"Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + print(f"✅ Found bot: {self.company_bot.name} (ID: {self.company_bot_id})") + except CompanyBot.DoesNotExist: + logger.error(f"CompanyBot with ID {self.company_bot_id} not found") + raise ValueError(f"CompanyBot with ID {self.company_bot_id} not found") + + def get_media_queryset(self): + """Get filtered media queryset""" + queryset = Media.objects.all() + + # Apply filters + if self.media_ids: + queryset = queryset.filter(id__in=self.media_ids) + print(f"🔍 Filter: Specific media IDs: {self.media_ids}") + + if self.company_slug: + queryset = queryset.filter( + Q(company_bot__company__slug=self.company_slug) | + Q(organization__slug=self.company_slug) + ) + print(f"🔍 Filter: Company slug = {self.company_slug}") + + if self.company_bot_id: + queryset = queryset.filter(company_bot_id=self.company_bot_id) + print(f"🔍 Filter: Bot ID = {self.company_bot_id}") + + # Order by ID for consistent processing + queryset = queryset.order_by('id') + + self.stats['total_media'] = queryset.count() + print(f"📊 Total media to process: {self.stats['total_media']}\n") + + return queryset + + def wait_for_task(self, task_result: AsyncResult, media_id: int) -> Dict[str, Any]: + """ + Wait for Celery task to complete and return result + + Args: + task_result: Celery AsyncResult object + media_id: Media ID being processed + + Returns: + Result dictionary + """ + elapsed = 0 + while elapsed < self.task_timeout: + if task_result.ready(): + try: + status_code = task_result.get(timeout=1) + + if 200 <= status_code < 300: + logger.info(f"Task completed successfully for media ID {media_id}: Status {status_code}") + return { + 'success': True, + 'media_id': media_id, + 'status_code': status_code, + 'message': 'Successfully processed via Celery', + 'task_id': task_result.id + } + else: + logger.error(f"Task failed for media ID {media_id}: Status {status_code}") + return { + 'success': False, + 'media_id': media_id, + 'status_code': status_code, + 'message': f'Task failed with status {status_code}', + 'task_id': task_result.id + } + except Exception as e: + logger.error(f"Error getting task result for media ID {media_id}: {str(e)}") + return { + 'success': False, + 'media_id': media_id, + 'message': f'Task error: {str(e)}', + 'task_id': task_result.id + } + + time.sleep(self.poll_interval) + elapsed += self.poll_interval + + # Timeout + logger.warning(f"Task timeout for media ID {media_id} after {self.task_timeout}s") + return { + 'success': False, + 'media_id': media_id, + 'message': f'Task timeout after {self.task_timeout}s', + 'task_id': task_result.id, + 'timeout': True + } + + def sync_single_media(self, media_id: int) -> Dict[str, Any]: + """ + Sync a single media file using Celery task + + Args: + media_id: Media ID to process + + Returns: + Result dictionary + """ + try: + logger.info(f"Processing media ID {media_id} via Celery") + + # Get media info for logging + try: + media = Media.objects.get(id=media_id) + file_name = media.file.name.split('/')[-1] if media.file else 'Unknown' + + # Get file size if available + try: + file_size_bytes = media.file.size + file_size_mb = file_size_bytes / (1024 * 1024) + logger.info(f"Media ID {media_id}: {file_name} ({file_size_mb:.2f} MB)") + except: + file_size_mb = None + logger.info(f"Media ID {media_id}: {file_name}") + except Media.DoesNotExist: + logger.error(f"Media ID {media_id} not found") + return { + 'success': False, + 'media_id': media_id, + 'message': 'Media not found' + } + + if self.dry_run: + logger.info(f"DRY RUN: Would process media ID {media_id} via Celery") + print(f" 🔍 DRY RUN: Would process media ID {media_id}") + print(f" File: {file_name}") + if file_size_mb: + print(f" Size: {file_size_mb:.2f} MB") + print(f" Method: Celery {'update' if self.update_mode else 'upsert'}") + + return { + 'success': True, + 'media_id': media_id, + 'file_name': file_name, + 'message': 'Dry run - not processed', + 'dry_run': True + } + + # Submit Celery task (same as extraction logic) + if self.update_mode: + task_result = update_in_vector_db.apply_async( + args=(media_id, self.company_slug), + countdown=0 + ) + logger.info(f"Submitted UPDATE task {task_result.id} for media ID {media_id}") + else: + task_result = save_in_vector_db.apply_async( + args=(media_id, self.company_slug), + countdown=0 + ) + logger.info(f"Submitted UPSERT task {task_result.id} for media ID {media_id}") + + print(f" 📤 Submitted Celery task {task_result.id}") + print(f" Waiting for completion (timeout: {self.task_timeout}s)...") + + # Wait for task to complete + result = self.wait_for_task(task_result, media_id) + + if result['success']: + print(f" ✅ Successfully processed media ID {media_id}") + elif result.get('timeout'): + print(f" ⏱️ Task timeout for media ID {media_id}") + else: + print(f" ❌ Failed to process media ID {media_id}") + + return result + + except Exception as e: + error_msg = str(e) + logger.exception(f"Error processing media ID {media_id}: {error_msg}") + print(f" ❌ Error processing media ID {media_id}: {error_msg}") + + return { + 'success': False, + 'media_id': media_id, + 'message': error_msg + } + + def sync_all(self) -> Dict[str, Any]: + """Sync all filtered media using Celery tasks""" + media_queryset = self.get_media_queryset() + + if self.stats['total_media'] == 0: + logger.warning("No media found to process") + print("⚠️ No media found to process!") + return self.stats + + logger.info(f"Starting Celery-based Vector DB Sync - Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"{'='*80}") + print(f"Starting Celery-based Vector DB Sync") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Method: {'UPDATE' if self.update_mode else 'UPSERT'}") + print(f"Batch size: {self.batch_size}") + print(f"Task timeout: {self.task_timeout}s") + print(f"Poll interval: {self.poll_interval}s") + print(f"{'='*80}\n") + + # Process in batches + media_ids = list(media_queryset.values_list('id', flat=True)) + + for i in range(0, len(media_ids), self.batch_size): + batch = media_ids[i:i + self.batch_size] + batch_num = (i // self.batch_size) + 1 + total_batches = (len(media_ids) + self.batch_size - 1) // self.batch_size + + logger.info(f"Processing batch {batch_num}/{total_batches}") + print(f"\n{'='*80}") + print(f"Batch {batch_num}/{total_batches} (Media IDs: {batch[0]} - {batch[-1]})") + print(f"{'='*80}") + + for media_id in batch: + self.stats['processed'] += 1 + progress = f"[{self.stats['processed']}/{self.stats['total_media']}]" + + print(f"\n{progress} Processing Media ID: {media_id}") + print(f"{'-'*80}") + + result = self.sync_single_media(media_id) + self.results.append(result) + + if result['success']: + self.stats['successful'] += 1 + elif result.get('timeout'): + self.stats['timeout'] += 1 + self.stats['failed'] += 1 + else: + self.stats['failed'] += 1 + + if not self.skip_errors: + logger.error("Stopping due to error (skip_errors=False)") + print(f"\n❌ Stopping due to error (skip_errors=False)") + self._print_summary() + return self.stats + + self._print_summary() + return self.stats + + def _print_summary(self): + """Print sync summary""" + logger.info(f"Sync Summary - Total: {self.stats['total_media']}, Successful: {self.stats['successful']}, Failed: {self.stats['failed']}, Timeout: {self.stats['timeout']}") + print(f"\n{'='*80}") + print(f"SYNC SUMMARY (Celery Mode)") + print(f"{'='*80}") + print(f"Mode: {'DRY RUN' if self.dry_run else 'LIVE'}") + print(f"Method: {'UPDATE' if self.update_mode else 'UPSERT'}") + print(f"Total Media: {self.stats['total_media']}") + print(f"Processed: {self.stats['processed']}") + print(f"Successful: {self.stats['successful']}") + print(f"Failed: {self.stats['failed']}") + print(f"Timeout: {self.stats['timeout']}") + print(f"{'='*80}\n") + + if self.stats['failed'] > 0: + logger.error(f"Failed media count: {self.stats['failed']}") + print("Failed Media:") + for result in self.results: + if not result['success']: + media_id = result.get('media_id', 'Unknown') + message = result.get('message', 'Unknown error') + task_id = result.get('task_id', 'N/A') + logger.error(f"Failed media ID {media_id}: {message} (task: {task_id})") + print(f" ❌ Media ID {media_id}: {message}") + print(f" Task ID: {task_id}") + print() + + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser( + description='Sync media files using Celery tasks (same as extraction logic)' + ) + + parser.add_argument('--company-slug', help='Filter by company slug') + parser.add_argument('--bot-id', type=int, help='Filter by CompanyBot ID') + parser.add_argument('--media-ids', help='Comma-separated list of media IDs') + parser.add_argument('--batch-size', type=int, default=10, help='Batch size (default: 10)') + parser.add_argument('--dry-run', action='store_true', help='Test mode') + parser.add_argument('--stop-on-error', action='store_true', help='Stop on first error') + parser.add_argument('--task-timeout', type=int, default=300, help='Task timeout in seconds (default: 300)') + parser.add_argument('--poll-interval', type=float, default=2.0, help='Poll interval in seconds (default: 2.0)') + parser.add_argument('--update', action='store_true', help='Use update instead of upsert') + + args = parser.parse_args() + + # Parse media IDs + media_ids = None + if args.media_ids: + try: + media_ids = [int(x.strip()) for x in args.media_ids.split(',')] + except ValueError: + print("❌ Error: Invalid media IDs format") + sys.exit(1) + + try: + syncer = MediaVectorDBSyncCelery( + company_slug=args.company_slug, + company_bot_id=args.bot_id, + media_ids=media_ids, + batch_size=args.batch_size, + dry_run=args.dry_run, + skip_errors=not args.stop_on_error, + task_timeout=args.task_timeout, + poll_interval=args.poll_interval, + update_mode=args.update + ) + + stats = syncer.sync_all() + + exit_code = 0 if stats['failed'] == 0 else 1 + logger.info(f"Sync completed with exit code {exit_code}") + sys.exit(exit_code) + + except Exception as e: + logger.exception(f"Fatal error: {e}") + print(f"\n❌ ERROR: {e}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/chatbot/scripts/theme_extraction.py b/chatbot/scripts/theme_extraction.py new file mode 100644 index 0000000..24e42e1 --- /dev/null +++ b/chatbot/scripts/theme_extraction.py @@ -0,0 +1,864 @@ +import json +import os +from chatbot.models import Story, ChatSession, CompanyChat, CompanyBot, Theme, ThemeType +from jinja2 import Template +import json_repair +import logging +from django.utils.timezone import make_aware +from datetime import datetime +from retrying import retry +from botocore.client import Config as BotoConfig +from botocore.exceptions import ClientError +from chatbot.llm_models.llm_script import handle_bedrock_model + +from chatbot.utils.chat_utils import format_message_as_per_bedrock_format + +logger = logging.getLogger('django') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + + +# ===== KEY CHANGES IMPLEMENTED ===== +# CHANGE 1: Theme master list from file - see get_master_themes_list() function +# - Single source of truth for themes +# - Creates file with defaults if not exists +# - Updates file when new themes discovered +# +# CHANGE 2: Uses CompanyBot route='/chaupal-theme-script' - see extract_themes_for_story() +# - Gets prompt from CompanyBot.context +# - Gets tools from CompanyBot.tool_context +# +# CHANGE 3: Session type filtering - see extract_themes_for_all_stories(), extract_themes_batch(), etc. +# - session_type parameter in all major functions +# - Default: ChatSession.objects.all() when session_type=None +# - Filtered: ChatSession.objects.filter(session_type=X) when specified +# =================================== + + +def get_master_themes_list_for_bot(company_bot): + """Get the master list of themes from Theme model for a specific bot""" + try: + theme_obj = Theme.objects.filter(bot=company_bot).first() + + if not theme_obj: + logger.info(f"No themes found for bot {company_bot.name}, returning empty list") + return [] + + # Check if bot uses master theme + if theme_obj.theme_type == ThemeType.MASTER and theme_obj.master_theme: + # Use themes from the master theme + master_theme_obj = theme_obj.master_theme + logger.info(f"Bot {company_bot.name} uses master theme from bot {master_theme_obj.bot.name}") + return master_theme_obj.themes if master_theme_obj.themes else [] + else: + # Use bot's own custom themes + logger.info(f"Bot {company_bot.name} uses custom themes - loaded {len(theme_obj.themes)} themes") + return theme_obj.themes if theme_obj.themes else [] + + except Exception as e: + logger.error(f"Error loading themes for bot {company_bot.name}: {str(e)}") + return [] + + +def save_themes_for_bot(themes, company_bot): + """Save themes to Theme model for a specific bot""" + try: + theme_obj, created = Theme.objects.get_or_create( + bot=company_bot, + defaults={'themes': themes, 'theme_type': ThemeType.CUSTOM} + ) + + if not created: + # Check if bot uses master theme + if theme_obj.theme_type == ThemeType.MASTER and theme_obj.master_theme: + # Update the master theme instead + master_theme_obj = theme_obj.master_theme + master_theme_obj.themes = themes + master_theme_obj.save() + logger.info(f"Updated master theme (bot: {master_theme_obj.bot.name}) with {len(themes)} themes") + else: + # Update bot's custom themes + theme_obj.themes = themes + theme_obj.save() + logger.info(f"Updated custom themes for bot {company_bot.name} with {len(themes)} themes") + else: + logger.info(f"Created new theme record for bot {company_bot.name} with {len(themes)} themes") + + return True + + except Exception as e: + logger.error(f"Error saving themes for bot {company_bot.name}: {str(e)}") + return False + + +def update_master_themes_list_for_bot(new_themes, company_bot): + """Update the master themes list with new themes for a specific bot""" + try: + theme_obj = Theme.objects.filter(bot=company_bot).first() + + if not theme_obj: + # Create new theme object with these themes + logger.info(f"Creating new theme object for bot {company_bot.name}") + return save_themes_for_bot(new_themes, company_bot) + + # Get current themes (considering master theme) + current_themes = set(get_master_themes_list_for_bot(company_bot)) + new_unique_themes = set(new_themes) - current_themes + + if new_unique_themes: + logger.info(f"New themes discovered for bot {company_bot.name}: {new_unique_themes}") + + # Determine which theme object to update + if theme_obj.theme_type == ThemeType.MASTER and theme_obj.master_theme: + # Update the master theme + target_theme_obj = theme_obj.master_theme + logger.info(f"Updating master theme (bot: {target_theme_obj.bot.name})") + else: + # Update bot's custom themes + target_theme_obj = theme_obj + logger.info(f"Updating custom themes for bot {company_bot.name}") + + # Update and save the themes + updated_themes = sorted(list(current_themes.union(new_unique_themes))) + target_theme_obj.themes = updated_themes + target_theme_obj.save() + + logger.info(f"Added {len(new_unique_themes)} new themes") + + return list(new_unique_themes) + + except Exception as e: + logger.error(f"Error updating themes for bot {company_bot.name}: {str(e)}") + return [] + + +def extract_themes_for_story(story): + """Extract themes from a story using LLM""" + try: + # Initialize other_params if it doesn't exist + if not story.other_params: + story.other_params = {} + + # Check if story already has themes (for logging purposes) + has_existing_themes = 'themes' in story.other_params and story.other_params['themes'] + if has_existing_themes: + logger.info(f"Story ID {story.id} has existing themes: {story.other_params['themes']} - will re-extract") + + # Get the bot from the story's session + session = ChatSession.objects.get(session=story.session) + if not session.company_bot: + logger.error(f"No company_bot found for session {story.session}") + return f"❌ No company_bot found for session {story.session}" + + # Use the session's company_bot for theme extraction + company_bot = session.company_bot + theme_bot = CompanyBot.objects.filter(route='/chaupal-theme-script').first() + logger.info(f"Using CompanyBot: {company_bot.name} (route: {company_bot.route}) and theme bot {theme_bot.name}") + + # Get master themes list for this specific bot (handles both custom and master themes) + master_themes = get_master_themes_list_for_bot(company_bot) + + # Get theme extraction prompt from company bot context + if theme_bot.context: + # Render Jinja2 template with themes variable + template = Template(theme_bot.context) + prompt = template.render(themes=master_themes) + logger.info(f"Using prompt from CompanyBot context with Jinja2 template rendering") + else: + # Fallback prompt if company bot has no context + prompt = get_theme_extraction_prompt(master_themes) + logger.info(f"Using fallback prompt as CompanyBot context is empty") + + # Get chat history + company_chats = CompanyChat.objects.filter(session=story.session).order_by('created_at') + + messages = format_message_as_per_bedrock_format(chats=company_chats) + formatted_prompt = [{"text": prompt}] + + # Get tools configuration from company bot or use default + if theme_bot.tool_context: + try: + tools = json.loads(theme_bot.tool_context) + logger.info("Using tools from CompanyBot tool_context") + except: + tools = get_theme_extraction_tools() + logger.info("Using default tools due to invalid tool_context") + else: + tools = get_theme_extraction_tools() + logger.info("Using default tools as CompanyBot has no tool_context") + + # Call LLM to extract themes + response = handle_bedrock_model( + system_prompt=formatted_prompt, + messages=messages, + model_name=theme_bot.llm_model, + temperature=theme_bot.bot_temperature, + max_token=theme_bot.max_token, + company_bot=theme_bot, + tools=tools + ) + + logger.info(f"LLM response for Story ID {story.id}: {response}") + result = get_clean_output(response=response) + logger.info(f"Cleaned result: {result}") + + if result and isinstance(result, str): + result = json_repair.repair_json(result, return_objects=True) + + # Extract themes from result + if result and isinstance(result, dict): + domain_themes = result.get('domain_themes', []) + issue_themes = result.get('issue_themes', []) + + if domain_themes or issue_themes: + # Update master themes list with any new themes found for this bot + all_themes = domain_themes + issue_themes + new_themes = update_master_themes_list_for_bot(all_themes, company_bot) + if new_themes: + logger.info(f"Story ID {story.id} introduced new themes: {new_themes}") + + # Save both types of themes to story + story.other_params['themes'] = { + 'domain_themes': domain_themes, + 'issue_themes': issue_themes, + } + story.save(update_fields=["other_params"]) + + if has_existing_themes: + logger.info(f"✅ Re-extracted themes for Story ID {story.id}: Domain: {domain_themes}, Issues: {issue_themes}") + return f"✅ Re-extracted themes for Story ID {story.id}: Domain: {domain_themes}, Issues: {issue_themes}" + else: + logger.info(f"✅ Extracted themes for Story ID {story.id}: Domain: {domain_themes}, Issues: {issue_themes}") + return f"✅ Extracted themes for Story ID {story.id}: Domain: {domain_themes}, Issues: {issue_themes}" + else: + logger.error(f"No themes extracted for Story ID {story.id}") + return f"⚠️ No themes extracted for Story ID {story.id}" + else: + logger.error(f"Invalid response format for Story ID {story.id}") + return f"❌ Invalid response format for Story ID {story.id}" + + except ChatSession.DoesNotExist: + logger.error(f"ChatSession not found for story session: {story.session}") + return f"❌ ChatSession not found for story session: {story.session}" + except Exception as e: + logger.error(f"❌ Error extracting themes for Story ID {story.id}: {str(e)}") + return f"❌ Error extracting themes for Story ID {story.id}: {str(e)}" + + +def get_theme_extraction_prompt(master_themes=None): + """Get prompt for theme extraction with master themes list""" + + # Format master themes for the prompt + if master_themes: + themes_list = "\n".join(f" - {theme}" for theme in master_themes) + prompt = f""" +You are an AI assistant that extracts themes from educational discussions. + +Based on the conversation, identify the main themes/topics being discussed. + +Here is our master list of themes. Try to map the discussion topics to these existing themes when possible: +{themes_list} + +If you identify a theme that is clearly discussed but not in the above list, you can add it as a new theme. + +Important instructions: +1. Only extract themes that are CLEARLY discussed in the conversation +2. Use the exact theme names from the master list when they match +3. If a topic is discussed that doesn't match any existing theme, create a new specific theme name +4. Be accurate and specific - don't assign themes that aren't actually discussed +5. Return between 1-5 themes that best represent the core topics +6. Themes should be in lowercase + +Return the themes as a list of strings. +""" + else: + # Fallback prompt if no master themes + prompt = """ +You are an AI assistant that extracts themes from educational discussions. + +Based on the conversation, identify the main themes/topics being discussed. + +Extract themes that best represent the core topics discussed in the conversation. +Return only the themes that are clearly discussed. +There can be one or multiple themes. Be specific and accurate. +Themes should be in lowercase. +""" + + return prompt + + +def get_theme_extraction_tools(): + """Get tools configuration for theme extraction with both domain and issue themes""" + tools = { + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "extract_themes", + "description": "Extract domain themes and issue themes from the discussion", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "domain_themes": { + "type": "array", + "items": {"type": "string"}, + "description": "Broad domain/sector themes (1-3 words each)" + }, + "issue_themes": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific issue/challenge themes (2-5 words each)" + } + }, + "required": ["domain_themes", "issue_themes"] + } + } + } + } + ] + } + } + + return tools + + +def extract_themes_for_all_stories(start=0, end=None, session_type=None): + """Extract themes for all stories + + Args: + start: Starting index + end: Ending index (None for all remaining) + session_type: ChatType to filter sessions (None for all types) + """ + # CHANGE 3: Use session_type parameter to filter, default to all if None + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + session_type_name = f"ChatType.{session_type}" if hasattr(session_type, 'name') else str(session_type) + logger.info(f"Filtering by session type: {session_type_name}") + else: + session_ids = list( + ChatSession.objects.all() + .values_list('session', flat=True) + ) + session_type_name = "ALL session types" + logger.info("Processing ALL session types") + + stories_query = Story.objects.filter(session__in=session_ids).order_by('-id') + + if end: + stories = stories_query[start:end] + else: + stories = stories_query[start:] + + total_count = stories.count() + print(f"\n{'=' * 60}") + print(f"Processing stories from {start} to {end if end else 'end'}... Total: {total_count}") + print(f"Session type: {session_type_name}") + print(f"{'=' * 60}\n") + logger.info( + f"Processing stories from {start} to {end if end else 'end'}... Total: {total_count}, Type: {session_type_name}") + + results = { + 'success': 0, + 'failed': 0, + 'already_has_themes': 0 + } + + for idx, story in enumerate(stories, 1): + print(f"[{idx}/{total_count}] Processing Story ID: {story.id}") + + result = extract_themes_for_story(story) + print(f" {result}") + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['already_has_themes'] += 1 + else: + results['failed'] += 1 + + # Optional progress update every 10 stories + if idx % 10 == 0: + print(f"\n--- Progress: {idx}/{total_count} stories processed ---") + print( + f" Success: {results['success']}, Already has themes: {results['already_has_themes']}, Failed: {results['failed']}\n") + + print(f"\n{'=' * 60}") + summary = f"Theme extraction completed:\n" + summary += f" - Successfully extracted: {results['success']}\n" + summary += f" - Already had themes: {results['already_has_themes']}\n" + summary += f" - Failed: {results['failed']}\n" + summary += f" - Total processed: {total_count}" + print(summary) + print(f"{'=' * 60}\n") + logger.info(summary) + return results + + +def extract_themes_batch(batch_size=100, session_type=None): + """Process stories in batches + + Args: + batch_size: Number of stories per batch + session_type: ChatType to filter sessions (None for all types) + """ + # CHANGE 3: Use session_type parameter to filter, default to all if None + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + session_type_name = f"ChatType.{session_type}" if hasattr(session_type, 'name') else str(session_type) + logger.info(f"Batch processing for session type: {session_type_name}") + else: + session_ids = list( + ChatSession.objects.all() + .values_list('session', flat=True) + ) + session_type_name = "ALL session types" + logger.info("Batch processing for ALL session types") + + total_stories = Story.objects.filter(session__in=session_ids).count() + print(f"\n{'=' * 60}") + print(f"Total stories to process: {total_stories}") + print(f"Session type: {session_type_name}") + print(f"Batch size: {batch_size}") + print(f"{'=' * 60}\n") + + processed = 0 + overall_results = { + 'success': 0, + 'failed': 0, + 'already_has_themes': 0 + } + + batch_num = 1 + while processed < total_stories: + print(f"\n🔄 Processing batch {batch_num}: stories {processed} to {min(processed + batch_size, total_stories)}") + + batch_results = extract_themes_for_all_stories( + start=processed, + end=processed + batch_size, + session_type=session_type + ) + + # Aggregate results + overall_results['success'] += batch_results['success'] + overall_results['failed'] += batch_results['failed'] + overall_results['already_has_themes'] += batch_results['already_has_themes'] + + processed += batch_size + batch_num += 1 + + # Optional: Add a small delay between batches + import time + time.sleep(2) + + print(f"\n{'=' * 60}") + print("FINAL SUMMARY:") + print(f" - Total stories processed: {total_stories}") + print(f" - Successfully extracted: {overall_results['success']}") + print(f" - Already had themes: {overall_results['already_has_themes']}") + print(f" - Failed: {overall_results['failed']}") + print(f"{'=' * 60}\n") + + return overall_results + + +def get_stories_by_date_range(start_time=None, end_time=None, session_type=None): + """Get story IDs for a specific time range + + Args: + start_time: Start datetime (default: 2025-01-01) + end_time: End datetime (default: now) + session_type: ChatType to filter sessions (None for all types) + """ + if not start_time: + start_time = make_aware(datetime(2025, 1, 1, 0, 0)) + if not end_time: + end_time = make_aware(datetime.now()) + + # CHANGE 3: Filter by session type if provided, otherwise get all + if session_type: + session_ids = list( + ChatSession.objects.filter( + session_type=session_type, + created_at__gte=start_time, + created_at__lte=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + session_type_name = f"ChatType.{session_type}" if hasattr(session_type, 'name') else str(session_type) + else: + session_ids = list( + ChatSession.objects.filter( + created_at__gte=start_time, + created_at__lte=end_time + ) + .order_by('created_at') + .values_list('session', flat=True) + ) + session_type_name = "ALL session types" + + if session_ids: + logger.info(f"Found {len(session_ids)} sessions ({session_type_name}) between {start_time} and {end_time}") + print(f"Found {len(session_ids)} sessions ({session_type_name})") + else: + print(f"No sessions found in the given date range for {session_type_name}.") + return [] + + story_ids = list( + Story.objects.filter(session__in=session_ids) + .order_by('-id') + .values_list('id', flat=True) + ) + + logger.info(f"Total stories in date range: {len(story_ids)}") + print(f"Total stories: {len(story_ids)}") + return story_ids + + +def extract_themes_for_specific_stories(story_ids): + """Extract themes for specific stories by their IDs""" + stories = Story.objects.filter(id__in=story_ids) + + print(f"\n{'=' * 60}") + print(f"Processing {stories.count()} specific stories for theme extraction...") + print(f"{'=' * 60}\n") + logger.info(f"Processing {stories.count()} stories for theme extraction...") + + results = { + 'success': 0, + 'failed': 0, + 'already_has_themes': 0 + } + + for idx, story in enumerate(stories, 1): + print(f"[{idx}/{stories.count()}] Processing Story ID: {story.id}") + + result = extract_themes_for_story(story) + print(f" {result}") + + if "✅" in result: + results['success'] += 1 + elif "🟡" in result: + results['already_has_themes'] += 1 + else: + results['failed'] += 1 + + print(f"\n{'=' * 60}") + summary = f"Theme extraction completed:\n" + summary += f" - Successfully extracted: {results['success']}\n" + summary += f" - Already had themes: {results['already_has_themes']}\n" + summary += f" - Failed: {results['failed']}\n" + summary += f" - Total processed: {stories.count()}" + print(summary) + print(f"{'=' * 60}\n") + logger.info(summary) + return results + + +def view_story_themes(story_id): + """View themes for a specific story""" + try: + story = Story.objects.get(id=story_id) + themes = story.other_params.get('themes', []) if story.other_params else [] + + print(f"\nStory ID: {story_id}") + print(f"Title: {story.title}") + + # Handle new format (dict) + if isinstance(themes, dict): + print(f"Domain Themes: {themes.get('domain_themes', [])}") + print(f"Issue Themes: {themes.get('issue_themes', [])}") + # Handle old format (list) + else: + print(f"Themes: {themes if themes else 'No themes found'}") + + return themes + except Story.DoesNotExist: + print(f"Story with ID {story_id} not found") + return None + + +def view_master_themes_by_bot(company_bot=None): + """View the current master themes list from Theme model""" + try: + if company_bot: + # View themes for specific bot + theme_obj = Theme.objects.filter(bot=company_bot).first() + + if not theme_obj: + print(f"No themes found for bot: {company_bot.name}") + return [] + + master_themes = get_master_themes_list_for_bot(company_bot) + + print(f"\n{'=' * 60}") + print(f"THEMES for bot: {company_bot.name}") + print(f"Theme Type: {theme_obj.get_theme_type_display()}") + if theme_obj.theme_type == ThemeType.MASTER and theme_obj.master_theme: + print(f"Using master theme from: {theme_obj.master_theme.bot.name}") + print(f"{'=' * 60}") + print(f"Total themes: {len(master_themes)}") + print(f"\nThemes (alphabetically sorted):") + print(f"{'-' * 60}") + + for i, theme in enumerate(sorted(master_themes), 1): + print(f"{i:3}. {theme}") + + print(f"{'=' * 60}\n") + + return master_themes + else: + # View themes for all bots + all_theme_objs = Theme.objects.select_related('bot', 'master_theme__bot').all() + + print(f"\n{'=' * 60}") + print(f"ALL BOT THEMES") + print(f"{'=' * 60}") + + for theme_obj in all_theme_objs: + print(f"\nBot: {theme_obj.bot.name} (ID: {theme_obj.bot.id})") + print(f"Theme Type: {theme_obj.get_theme_type_display()}") + + if theme_obj.theme_type == ThemeType.MASTER and theme_obj.master_theme: + print(f"Using master theme from: {theme_obj.master_theme.bot.name}") + themes = theme_obj.master_theme.themes + else: + themes = theme_obj.themes + + print(f"Total themes: {len(themes)}") + print(f"Themes: {', '.join(sorted(themes[:10]))}") + if len(themes) > 10: + print(f"... and {len(themes) - 10} more") + print(f"{'-' * 40}") + + print(f"{'=' * 60}\n") + + return all_theme_objs + + except Exception as e: + print(f"Error viewing master themes: {str(e)}") + return [] + + +def export_bot_themes_to_file(company_bot, filename=None): + """Export a specific bot's themes to a JSON file""" + try: + if not filename: + filename = f"themes_export_{company_bot.id}_{company_bot.name.replace(' ', '_')}.json" + + master_themes = get_master_themes_list_for_bot(company_bot) + + # Get theme statistics for this bot + theme_stats = {} + stories_with_bot = Story.objects.filter( + session__in=ChatSession.objects.filter(company_bot=company_bot).values_list('session', flat=True) + ) + + for story in stories_with_bot: + if story.other_params and 'themes' in story.other_params: + themes = story.other_params.get('themes', {}) + if isinstance(themes, dict): + for theme in themes.get('all_themes', []): + theme_stats[theme] = theme_stats.get(theme, 0) + 1 + + export_data = { + "bot_id": company_bot.id, + "bot_name": company_bot.name, + "bot_route": company_bot.route, + "themes": master_themes, + "theme_count": len(master_themes), + "theme_statistics": theme_stats, + "export_date": datetime.now().isoformat() + } + + with open(filename, 'w', encoding='utf-8') as f: + json.dump(export_data, f, indent=2, ensure_ascii=False) + + print(f"✅ Exported {len(master_themes)} themes for bot {company_bot.name} to {filename}") + return filename + + except Exception as e: + logger.error(f"Error exporting themes: {str(e)}") + print(f"❌ Error exporting themes: {str(e)}") + return None + + +def view_theme_statistics(): + """View statistics about themes usage across all stories""" + try: + theme_count = {} + domain_theme_count = {} + issue_theme_count = {} + total_stories_with_themes = 0 + + # Get all stories with themes + stories_with_themes = Story.objects.filter( + other_params__themes__isnull=False + ).exclude( + other_params__themes=[] + ) + + # Count theme occurrences + for story in stories_with_themes: + if story.other_params and 'themes' in story.other_params: + themes = story.other_params.get('themes', []) + + # Handle new format (dict with domain_themes and issue_themes) + if isinstance(themes, dict): + total_stories_with_themes += 1 + + # Count domain themes + for theme in themes.get('domain_themes', []): + domain_theme_count[theme] = domain_theme_count.get(theme, 0) + 1 + theme_count[theme] = theme_count.get(theme, 0) + 1 + + # Count issue themes + for theme in themes.get('issue_themes', []): + issue_theme_count[theme] = issue_theme_count.get(theme, 0) + 1 + theme_count[theme] = theme_count.get(theme, 0) + 1 + + # Handle old format (list) + elif isinstance(themes, list) and themes: + total_stories_with_themes += 1 + for theme in themes: + theme_count[theme] = theme_count.get(theme, 0) + 1 + + # Sort themes by count + sorted_all_themes = sorted(theme_count.items(), key=lambda x: x[1], reverse=True) + sorted_domain_themes = sorted(domain_theme_count.items(), key=lambda x: x[1], reverse=True) + sorted_issue_themes = sorted(issue_theme_count.items(), key=lambda x: x[1], reverse=True) + + print(f"\n{'=' * 60}") + print(f"THEME STATISTICS") + print(f"{'=' * 60}") + print(f"Total stories with themes: {total_stories_with_themes}") + print(f"Total unique themes (all): {len(theme_count)}") + print(f"Total unique domain themes: {len(domain_theme_count)}") + print(f"Total unique issue themes: {len(issue_theme_count)}") + + # Show all themes + print(f"\nAll themes (sorted by frequency):") + print(f"{'Theme':<40} {'Count':<10} {'Percentage':<10}") + print(f"{'-' * 60}") + for theme, count in sorted_all_themes[:20]: # Show top 20 + percentage = (count / total_stories_with_themes * 100) if total_stories_with_themes > 0 else 0 + print(f"{theme:<40} {count:<10} {percentage:.1f}%") + + print(f"{'=' * 60}\n") + + return sorted_all_themes + + except Exception as e: + logger.error(f"Error viewing theme statistics: {str(e)}") + print(f"Error viewing theme statistics: {str(e)}") + return [] + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response): + """Clean and format the LLM response""" + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + return response_json_content + +# ============================================================================= +# USAGE INSTRUCTIONS +# ============================================================================= +# +# SETUP: The script will create master_themes.json automatically on first run +# with default themes. You can also create it manually: +# { +# "themes": ["education", "health", "agriculture", ...], +# "last_updated": "2025-01-17T...", +# "total_themes": 30 +# } +# +# 1. Extract themes for ALL stories (all session types): +# extract_themes_for_all_stories() +# +# 2. Extract themes for specific session type: +# extract_themes_for_all_stories(session_type=ChatType.shikshaChaupal) +# +# 3. Process in batches (recommended for large datasets): +# extract_themes_batch(batch_size=100) +# extract_themes_batch(batch_size=100, session_type=ChatType.shikshaChaupal) +# +# 4. Extract themes for a specific date range: +# from datetime import datetime +# from django.utils.timezone import make_aware +# +# start = make_aware(datetime(2025, 7, 25)) +# end = make_aware(datetime(2025, 8, 31)) +# story_ids = get_stories_by_date_range(start, end, 'normal') +# extract_themes_for_specific_stories(story_ids) +# +# # For specific session type: +# story_ids = get_stories_by_date_range(start, end, session_type=ChatType.shikshaChaupal) +# extract_themes_for_specific_stories(story_ids) +# +# 5. Extract themes for a specific range of stories: +# extract_themes_for_all_stories(start=0, end=100) +# extract_themes_for_all_stories(start=0, end=100, session_type=ChatType.shikshaChaupal) +# +# 6. View themes for a specific story: +# view_story_themes(story_id=12345) +# +# 7. Extract themes for specific story IDs: +# story_ids = [123, 456, 789] +# extract_themes_for_specific_stories(story_ids) +# +# 8. View current master themes list (from file): +# view_master_themes() +# +# 9. View theme statistics: +# view_theme_statistics() +# +# 10. Export master themes to file: +# export_master_themes_to_file("my_themes_export.json") +# +# 11. Manually save themes to file: +# themes = ["education", "health", "agriculture"] +# save_themes_to_file(themes, "custom_themes.json") +# +# 12. Add new themes to master list: +# new_themes = ["disaster management", "mental health"] +# add_new_themes_to_master_list(new_themes) +# +# IMPORTANT NOTES: +# - The script uses CompanyBot with route='/chaupal-theme-script' for theme extraction +# - Master themes are automatically updated when new themes are discovered +# - Session type defaults to ALL types when not specified +# - Make sure CompanyBot exists with proper context (prompt) and tool_context \ No newline at end of file diff --git a/chatbot/scripts/theme_extraction_in_file.py b/chatbot/scripts/theme_extraction_in_file.py new file mode 100644 index 0000000..25f606c --- /dev/null +++ b/chatbot/scripts/theme_extraction_in_file.py @@ -0,0 +1,665 @@ +import json +import os +import logging +from datetime import datetime +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model + + +logger = logging.getLogger('django') +llm_retry_number = int(os.getenv('LLM_RETRY_NUMBER', 3)) +AWS_KEY = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + + +def retry_if_result_none(result): + return result is None + + +def get_clean_output(response): + """Clean and format the LLM response""" + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + response_json_content = response + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + if isinstance(response_json_content, dict) and response_json_content.get("type"): + if "value" in response_json_content: + value = response_json_content.get("value") + elif "parameters" in response_json_content: + value = response_json_content.get("parameters") + else: + value = None + if value and isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + else: + response_json_content = {} + + return response_json_content + + +def get_story_count(language=None, session_type=None, start_date=None, end_date=None): + """Get count of stories matching the given filters""" + # Build query + query = Story.objects.all() + + # Apply filters + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + query = query.filter(session__in=session_ids) + + if start_date: + query = query.filter(created_at__gte=start_date) + if end_date: + query = query.filter(created_at__lte=end_date) + + if language: + query = query.filter(language=language) + + total_count = query.count() + + # Count stories with themes + stories_with_themes = query.filter( + other_params__themes__isnull=False + ).count() + + # Count by language if no language filter + language_counts = {} + if not language: + language_counts = dict(query.values_list('language').annotate(count=models.Count('id'))) + + print(f"\n{'=' * 60}") + print("STORY COUNT SUMMARY") + if language: + print(f"Language filter: {language}") + if session_type: + print(f"Session type filter: {session_type}") + if start_date or end_date: + print(f"Date range: {start_date or 'beginning'} to {end_date or 'now'}") + print(f"{'=' * 60}") + print(f"Total stories: {total_count}") + print( + f"Stories with themes: {stories_with_themes} ({stories_with_themes / total_count * 100:.1f}%)" if total_count > 0 else "Stories with themes: 0") + print(f"Stories without themes: {total_count - stories_with_themes}") + + if language_counts: + print(f"\nBreakdown by language:") + for lang, count in sorted(language_counts.items(), key=lambda x: x[1], reverse=True): + print(f" {lang}: {count}") + + print(f"{'=' * 60}\n") + + return { + 'total': total_count, + 'with_themes': stories_with_themes, + 'without_themes': total_count - stories_with_themes, + 'language_breakdown': language_counts + } + + +def extract_themes_for_specific_stories(story_ids): + """Extract themes for specific stories by their IDs""" + stories = Story.objects.filter(id__in=story_ids) + + print(f"\n{'=' * 60}") + print(f"Processing {stories.count()} specific stories for theme extraction...") + print(f"{'=' * 60}\n") + logger.info(f"Processing {stories.count()} stories for theme extraction...") + + results = { + 'success': 0, + 'failed': 0, + 'skipped': 0 + } + + for idx, story in enumerate(stories, 1): + print(f"[{idx}/{stories.count()}] Processing Story ID: {story.id}") + + result = extract_themes_for_story(story) + print(f" {result}") + + if "✅" in result: + results['success'] += 1 + elif "❌" in result: + results['failed'] += 1 + else: + results['skipped'] += 1 + + print(f"\n{'=' * 60}") + summary = f"Theme extraction completed:\n" + summary += f" - Successfully extracted: {results['success']}\n" + summary += f" - Failed: {results['failed']}\n" + summary += f" - Skipped: {results['skipped']}\n" + summary += f" - Total processed: {stories.count()}" + print(summary) + print(f"{'=' * 60}\n") + logger.info(summary) + return results + + +from chatbot.models import Story, ChatSession, CompanyChat, CompanyBot +from chatbot.utils.chat_utils import format_message_as_per_bedrock_format +from jinja2 import Template +from django.db import models + +# Master themes file path +MASTER_THEMES_FILE = 'master_themes.json' + + +def get_master_themes(): + """Get master themes from file""" + try: + if os.path.exists(MASTER_THEMES_FILE): + with open(MASTER_THEMES_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return { + 'domain_themes': data.get('domain_themes', []), + 'issue_themes': data.get('issue_themes', []) + } + else: + # Create empty file if not exists + empty_themes = { + 'domain_themes': [], + 'issue_themes': [], + 'last_updated': datetime.now().isoformat() + } + save_master_themes([], []) + return { + 'domain_themes': [], + 'issue_themes': [] + } + except Exception as e: + logger.error(f"Error loading master themes: {str(e)}") + return {'domain_themes': [], 'issue_themes': []} + + +def save_master_themes(domain_themes, issue_themes): + """Save master themes to file""" + try: + data = { + 'domain_themes': sorted(list(set(domain_themes))), + 'issue_themes': sorted(list(set(issue_themes))), + 'last_updated': datetime.now().isoformat(), + 'total_domain_themes': len(set(domain_themes)), + 'total_issue_themes': len(set(issue_themes)) + } + + with open(MASTER_THEMES_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + logger.info(f"Saved {len(domain_themes)} domain themes and {len(issue_themes)} issue themes") + return True + except Exception as e: + logger.error(f"Error saving master themes: {str(e)}") + return False + + +def update_master_themes(new_domain_themes, new_issue_themes): + """Update master themes with new ones""" + try: + current_themes = get_master_themes() + + # Merge and deduplicate + updated_domain = set(current_themes['domain_themes']) | set(new_domain_themes) + updated_issues = set(current_themes['issue_themes']) | set(new_issue_themes) + + # Find what's new + new_domains = set(new_domain_themes) - set(current_themes['domain_themes']) + new_issues = set(new_issue_themes) - set(current_themes['issue_themes']) + + if new_domains or new_issues: + save_master_themes(list(updated_domain), list(updated_issues)) + logger.info(f"Added {len(new_domains)} new domain themes and {len(new_issues)} new issue themes") + return {'new_domains': list(new_domains), 'new_issues': list(new_issues)} + + return {'new_domains': [], 'new_issues': []} + except Exception as e: + logger.error(f"Error updating master themes: {str(e)}") + return {'new_domains': [], 'new_issues': []} + + +def extract_themes_for_story(story): + """Extract themes from a single story""" + try: + # Check if already has themes + if story.other_params and 'themes' in story.other_params: + existing = story.other_params['themes'] + logger.info(f"Story {story.id} has existing themes, re-extracting...") + + # Get session and bot + session = ChatSession.objects.get(session=story.session) + if not session.company_bot: + return f"❌ No company_bot found for session {story.session}" + + # Get theme extraction bot + theme_bot = CompanyBot.objects.filter(route='/chaupal-theme-script').first() + if not theme_bot: + return f"❌ Theme extraction bot not found" + + # Get master themes + master_themes = get_master_themes() + + # Generate prompt from bot context + if not theme_bot.context: + return f"❌ Theme bot has no context/prompt configured" + + template = Template(theme_bot.context) + prompt = template.render( + domain_themes=master_themes['domain_themes'], + issue_themes=master_themes['issue_themes'] + ) + + # Get chat history + company_chats = CompanyChat.objects.filter(session=story.session).order_by('created_at') + messages = format_message_as_per_bedrock_format(chats=company_chats) + + # Get tools from bot + if not theme_bot.tool_context: + return f"❌ Theme bot has no tool_context configured" + + try: + tools = json.loads(theme_bot.tool_context) + except Exception as e: + return f"❌ Invalid tool_context in theme bot: {str(e)}" + + # Call LLM + response = handle_bedrock_model( + system_prompt=[{"text": prompt}], + messages=messages, + model_name=theme_bot.llm_model, + temperature=theme_bot.bot_temperature, + max_token=theme_bot.max_token, + company_bot=theme_bot, + tools=tools + ) + + logger.info(f"LLM response for Story ID {story.id}: {response}") + result = get_clean_output(response=response) + logger.info(f"Cleaned result: {result}") + + if result and isinstance(result, str): + result = json_repair.repair_json(result, return_objects=True) + + if result and isinstance(result, dict): + domain_themes = result.get('domain_themes', []) + issue_themes = result.get('issue_themes', []) + + if domain_themes or issue_themes: + # Update master themes if new ones found + new_themes = update_master_themes(domain_themes, issue_themes) + + if new_themes['new_domains'] or new_themes['new_issues']: + logger.info( + f"Story ID {story.id} introduced new themes - Domains: {new_themes['new_domains']}, Issues: {new_themes['new_issues']}") + + # Save to story + if not story.other_params: + story.other_params = {} + + story.other_params['themes'] = { + 'domain_themes': domain_themes, + 'issue_themes': issue_themes + } + story.save(update_fields=['other_params']) + + return f"✅ Extracted - Domain: {domain_themes}, Issues: {issue_themes}" + else: + logger.error(f"No themes extracted for Story ID {story.id}") + return f"⚠️ No themes extracted for Story ID {story.id}" + else: + logger.error(f"Invalid response format for Story ID {story.id}") + return f"❌ Invalid response format for Story ID {story.id}" + + except ChatSession.DoesNotExist: + logger.error(f"ChatSession not found for story session: {story.session}") + return f"❌ ChatSession not found for story session: {story.session}" + except Exception as e: + logger.error(f"Error extracting themes for story {story.id}: {str(e)}") + return f"❌ Error: {str(e)}" + + +def extract_themes_for_all_stories(start=0, end=None, language=None, session_type=None, start_date=None, end_date=None): + """Extract themes for stories with optional filters""" + # Build query + stories_query = Story.objects.all() + + # Apply session type filter if provided + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + stories_query = stories_query.filter(session__in=session_ids) + logger.info(f"Filtering stories by session type: {session_type}") + + # Apply date range filter if provided + if start_date or end_date: + if start_date: + stories_query = stories_query.filter(created_at__gte=start_date) + if end_date: + stories_query = stories_query.filter(created_at__lte=end_date) + logger.info(f"Filtering stories by date range: {start_date} to {end_date}") + + # Apply language filter if provided + if language: + stories_query = stories_query.filter(language=language) + logger.info(f"Filtering stories by language: {language}") + + stories_query = stories_query.order_by('-id') + + # Apply range + if end: + stories = stories_query[start:end] + else: + stories = stories_query[start:] + + total_count = stories.count() + + print(f"\n{'=' * 60}") + print(f"Processing {total_count} stories (start: {start}, end: {end or 'all'})") + if language: + print(f"Language filter: {language}") + if session_type: + print(f"Session type filter: {session_type}") + if start_date or end_date: + print(f"Date range: {start_date or 'beginning'} to {end_date or 'now'}") + print(f"{'=' * 60}\n") + + results = { + 'success': 0, + 'failed': 0, + 'skipped': 0 + } + + for idx, story in enumerate(stories, 1): + print(f"[{idx}/{total_count}] Story ID: {story.id} (Language: {story.language})") + + result = extract_themes_for_story(story) + print(f" {result}") + + if "✅" in result: + results['success'] += 1 + elif "❌" in result: + results['failed'] += 1 + else: + results['skipped'] += 1 + + # Progress update every 10 + if idx % 10 == 0: + print(f"\n--- Progress: {idx}/{total_count} ---") + print(f"Success: {results['success']}, Failed: {results['failed']}, Skipped: {results['skipped']}\n") + + # Summary + print(f"\n{'=' * 60}") + print(f"SUMMARY:") + print(f" - Success: {results['success']}") + print(f" - Failed: {results['failed']}") + print(f" - Skipped: {results['skipped']}") + print(f" - Total: {total_count}") + print(f"{'=' * 60}\n") + + return results + + +def extract_themes_batch(batch_size=100, language=None, session_type=None, start_date=None, end_date=None): + """Process stories in batches with multiple filters""" + # Get total count + query = Story.objects.all() + + # Apply filters for count + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + query = query.filter(session__in=session_ids) + + if start_date: + query = query.filter(created_at__gte=start_date) + if end_date: + query = query.filter(created_at__lte=end_date) + + if language: + query = query.filter(language=language) + + total_stories = query.count() + + print(f"\n{'=' * 60}") + print(f"Total stories: {total_stories}") + print(f"Batch size: {batch_size}") + if language: + print(f"Language filter: {language}") + if session_type: + print(f"Session type filter: {session_type}") + if start_date or end_date: + print(f"Date range: {start_date or 'beginning'} to {end_date or 'now'}") + print(f"{'=' * 60}\n") + + processed = 0 + overall_results = { + 'success': 0, + 'failed': 0, + 'skipped': 0 + } + + batch_num = 1 + while processed < total_stories: + print(f"\n🔄 Batch {batch_num}: stories {processed} to {min(processed + batch_size, total_stories)}") + + batch_results = extract_themes_for_all_stories( + start=processed, + end=processed + batch_size, + language=language, + session_type=session_type, + start_date=start_date, + end_date=end_date + ) + + # Aggregate results + for key in overall_results: + overall_results[key] += batch_results.get(key, 0) + + processed += batch_size + batch_num += 1 + + # Small delay between batches + import time + time.sleep(2) + + print(f"\n{'=' * 60}") + print("FINAL SUMMARY:") + print(f" - Total processed: {total_stories}") + print(f" - Success: {overall_results['success']}") + print(f" - Failed: {overall_results['failed']}") + print(f" - Skipped: {overall_results['skipped']}") + print(f"{'=' * 60}\n") + + return overall_results + + +def view_story_themes(story_id): + """View themes for a specific story""" + try: + story = Story.objects.get(id=story_id) + themes = story.other_params.get('themes', {}) if story.other_params else {} + + print(f"\nStory ID: {story_id}") + print(f"Title: {story.title}") + print(f"Language: {story.language}") + print(f"Domain Themes: {themes.get('domain_themes', [])}") + print(f"Issue Themes: {themes.get('issue_themes', [])}") + + return themes + except Story.DoesNotExist: + print(f"Story {story_id} not found") + return None + + +def view_master_themes(): + """View current master themes""" + themes = get_master_themes() + + print(f"\n{'=' * 60}") + print("MASTER THEMES") + print(f"{'=' * 60}") + + print(f"\nDomain Themes ({len(themes['domain_themes'])}):") + print("-" * 40) + for i, theme in enumerate(sorted(themes['domain_themes']), 1): + print(f"{i:3}. {theme}") + + print(f"\nIssue Themes ({len(themes['issue_themes'])}):") + print("-" * 40) + for i, theme in enumerate(sorted(themes['issue_themes']), 1): + print(f"{i:3}. {theme}") + + print(f"{'=' * 60}\n") + + return themes + + +def get_theme_statistics(language=None, session_type=None, start_date=None, end_date=None): + """Get statistics about theme usage with multiple filters""" + domain_count = {} + issue_count = {} + total_stories = 0 + + # Build query + query = Story.objects.filter( + other_params__themes__isnull=False + ) + + # Apply filters + if session_type: + session_ids = list( + ChatSession.objects.filter(session_type=session_type) + .values_list('session', flat=True) + ) + query = query.filter(session__in=session_ids) + + if start_date: + query = query.filter(created_at__gte=start_date) + if end_date: + query = query.filter(created_at__lte=end_date) + + if language: + query = query.filter(language=language) + + # Count occurrences + for story in query: + if story.other_params and 'themes' in story.other_params: + themes = story.other_params['themes'] + total_stories += 1 + + for theme in themes.get('domain_themes', []): + domain_count[theme] = domain_count.get(theme, 0) + 1 + + for theme in themes.get('issue_themes', []): + issue_count[theme] = issue_count.get(theme, 0) + 1 + + # Sort by frequency + sorted_domains = sorted(domain_count.items(), key=lambda x: x[1], reverse=True) + sorted_issues = sorted(issue_count.items(), key=lambda x: x[1], reverse=True) + + print(f"\n{'=' * 60}") + print("THEME STATISTICS") + if language: + print(f"Language: {language}") + if session_type: + print(f"Session type: {session_type}") + if start_date or end_date: + print(f"Date range: {start_date or 'beginning'} to {end_date or 'now'}") + print(f"{'=' * 60}") + print(f"Total stories with themes: {total_stories}") + + print(f"\nTop Domain Themes:") + print(f"{'Theme':<40} {'Count':<10} {'%':<10}") + print("-" * 60) + for theme, count in sorted_domains[:10]: + pct = (count / total_stories * 100) if total_stories > 0 else 0 + print(f"{theme:<40} {count:<10} {pct:.1f}%") + + print(f"\nTop Issue Themes:") + print(f"{'Theme':<40} {'Count':<10} {'%':<10}") + print("-" * 60) + for theme, count in sorted_issues[:10]: + pct = (count / total_stories * 100) if total_stories > 0 else 0 + print(f"{theme:<40} {count:<10} {pct:.1f}%") + + print(f"{'=' * 60}\n") + + return { + 'domain_themes': sorted_domains, + 'issue_themes': sorted_issues, + 'total_stories': total_stories + } + +# ============================================================================ +# USAGE EXAMPLES +# ============================================================================ +# +# 1. Extract themes for all stories: +# extract_themes_for_all_stories() +# +# 2. Extract themes with language filter: +# extract_themes_for_all_stories(language='hi') +# +# 3. Extract themes with session type filter: +# from chatbot.models import ChatType +# extract_themes_for_all_stories(session_type=ChatType.shikshaChaupal) +# +# 4. Extract themes with date range: +# from datetime import datetime +# from django.utils.timezone import make_aware +# start = make_aware(datetime(2025, 1, 1)) +# end = make_aware(datetime(2025, 1, 31)) +# extract_themes_for_all_stories(start_date=start, end_date=end) +# +# 5. Combine multiple filters: +# extract_themes_for_all_stories( +# language='hi', +# session_type=ChatType.shikshaChaupal, +# start_date=start, +# end_date=end +# ) +# +# 6. Batch processing with filters: +# extract_themes_batch( +# batch_size=100, +# language='en', +# session_type=ChatType.normal +# ) +# +# 7. Extract themes for specific story IDs: +# story_ids = [123, 456, 789] +# extract_themes_for_specific_stories(story_ids) +# +# 8. Get story count with filters: +# get_story_count() # All stories +# get_story_count(language='hi', session_type=ChatType.shikshaChaupal) +# +# 9. View story themes: +# view_story_themes(12345) +# +# 10. View master themes: +# view_master_themes() +# +# 11. Get theme statistics with filters: +# get_theme_statistics() # All +# get_theme_statistics(language='hi', session_type=ChatType.shikshaChaupal) +# +# 12. Manually update master themes: +# update_master_themes( +# new_domain_themes=['technology', 'environment'], +# new_issue_themes=['digital divide', 'climate change'] +# ) +# \ No newline at end of file diff --git a/chatbot/scripts/translate_vern_responses.py b/chatbot/scripts/translate_vern_responses.py new file mode 100644 index 0000000..f934996 --- /dev/null +++ b/chatbot/scripts/translate_vern_responses.py @@ -0,0 +1,76 @@ +from chatbot.models import CompanyChat, ChatSession, CompanyBot +from chatbot.utils.audio_provider_utils import text_translate_provider +from django.db.models import F +import logging + +logger = logging.getLogger('django') + + +def get_untranslated_bot_chats(): + """ + Returns CompanyChat rows where: + - receiver_id = 1 + - session exists in ChatSession (inner join) + - translated_message == message (no real translation happened) + """ + valid_sessions = ChatSession.objects.filter( + session_type='telangana-ptm-pilot' + ).exclude(language='en').values_list('session', flat=True) + + chats = CompanyChat.objects.filter( + receiver_id=1, + session__in=valid_sessions, + translated_message=F('message') + ) + + return chats + + +def print_untranslated_bot_chats(): + chats = get_untranslated_bot_chats() + print(f"Total untranslated bot chats: {chats.count()}") + for chat in chats: + print(f"ID: {chat.id} | Session: {chat.session} | Message: {chat.message[:80]}") + + +def translate_untranslated_bot_chats(): + company_bot = CompanyBot.objects.filter(route='/classify-school-telangana-ptm').first() + if not company_bot: + logger.error("CompanyBot with route='/telangana_ptm_pilot' not found") + return + + session_language_map = { + s.session: s.language + for s in ChatSession.objects.filter( + session_type='telangana-ptm-pilot' + ).exclude(language='en') + } + + chats = get_untranslated_bot_chats() + total = chats.count() + print(f"Translating {total} chats...") + + success, failed = 0, 0 + for chat in chats: + source_language = session_language_map.get(chat.session) + if not source_language: + logger.warning(f"No language found for session {chat.session}, skipping chat {chat.id}") + failed += 1 + continue + + response = text_translate_provider( + message_body=chat.message, + target_language='en', + source_language=source_language, + company_bot=company_bot, + ) + + if response.get('status') == 200 and response.get('content') != chat.message: + chat.translated_message = response['content'] + chat.save(update_fields=['translated_message']) + success += 1 + else: + logger.error(f"Translation failed for chat {chat.id}: {response.get('content')}") + failed += 1 + + print(f"Done. Success: {success}, Failed: {failed}") diff --git a/chatbot/serializer/__init__.py b/chatbot/serializer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/serializer/base_serializer.py b/chatbot/serializer/base_serializer.py new file mode 100644 index 0000000..aff93eb --- /dev/null +++ b/chatbot/serializer/base_serializer.py @@ -0,0 +1,30 @@ +from rest_framework import serializers +from chatbot.models import ChatSession +from chatbot.models.profile_models import Profile +from chatbot.models.company_models import Voice + + +class VoiceSerializer(serializers.ModelSerializer): + class Meta: + model = Voice + fields = '__all__' + + +class ChatSessionSerializer(serializers.ModelSerializer): + phone = serializers.CharField(write_only=True) + company_slug = serializers.CharField(write_only=True) + + class Meta: + model = ChatSession + fields = '__all__' + + def create(self, validated_data): + phone = validated_data.pop('phone', None) + company_slug = validated_data.pop('company_slug', None) + if phone and company_slug: + try: + profile = Profile.objects.get(phone=phone, company__slug=company_slug) + except Profile.DoesNotExist: + raise serializers.ValidationError("Profile does not exist.") + validated_data['profile'] = profile + return super().create(validated_data) diff --git a/chatbot/serializer/company_serializer.py b/chatbot/serializer/company_serializer.py new file mode 100644 index 0000000..154de30 --- /dev/null +++ b/chatbot/serializer/company_serializer.py @@ -0,0 +1,101 @@ +from rest_framework import serializers +from chatbot.models import BotVernacular +from chatbot.models.company_models import CompanyStateMachine, CompanyBot, Company, ImageConfiguration, Flow + + +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ('name', 'slug') + + +class CompanyBotSerializer(serializers.ModelSerializer): + company = CompanySerializer(read_only=True) + statemachine_length = serializers.SerializerMethodField() + + class Meta: + model = CompanyBot + fields = '__all__' + + def get_statemachine_length(self, obj): + return obj.companystatemachine_set.count() + + +class CompanyStateMachineSerializer(serializers.ModelSerializer): + class Meta: + model = CompanyStateMachine + fields = '__all__' + + +class BotVernacularSerializer(serializers.ModelSerializer): + company_bot = CompanyBotSerializer(read_only=True) + default_name = serializers.SerializerMethodField() + + class Meta: + model = BotVernacular + fields = '__all__' + + def get_default_name(self, obj): + english_bot = BotVernacular.objects.filter(company_bot=obj.company_bot, language='en').first() + return english_bot.name if english_bot else "" + + +class ImageConfigurationSerializer(serializers.ModelSerializer): + """Serializer for ImageConfiguration model.""" + image_size_mb = serializers.SerializerMethodField() + + class Meta: + model = ImageConfiguration + fields = ('id', 'name', 'max_images', 'image_size', 'image_size_mb') + read_only_fields = ('id',) + + def get_image_size_mb(self, obj): + """Convert image size from bytes to MB for easier reading.""" + return round(obj.image_size / 1048576, 2) + + +class FlowLanguagesSerializer(serializers.ModelSerializer): + """Serializer for Flow languages.""" + + class Meta: + model = Flow + fields = ('flow_route', 'languages') + read_only_fields = ('flow_route', 'languages') + + +class ChildFlowSerializer(serializers.ModelSerializer): + """Serializer for child flow minimal information.""" + + class Meta: + model = Flow + fields = ('flow_route', 'flow_name', 'active') + read_only_fields = ('flow_route', 'flow_name', 'active') + + +class FlowConnectionInfoSerializer(serializers.ModelSerializer): + """Serializer for Flow connection information.""" + bot_route = serializers.CharField(source='bot.route', read_only=True) + isParentFlow = serializers.SerializerMethodField() + children_flows = serializers.SerializerMethodField() + image_config = serializers.SerializerMethodField() + + class Meta: + model = Flow + fields = ('flow_route', 'websocket_url', 'bot_route', 'isParentFlow', 'children_flows', 'image_config', 'create_story') + read_only_fields = ('flow_route', 'websocket_url', 'bot_route', 'isParentFlow', 'children_flows', 'image_config') + + def get_isParentFlow(self, obj): + """Check if this flow has children.""" + return obj.child_flows.exists() + + def get_children_flows(self, obj): + """Get list of child flows if this is a parent flow.""" + if obj.child_flows.exists(): + return ChildFlowSerializer(obj.child_flows.all(), many=True).data + return [] + + def get_image_config(self, obj): + """Get image configuration for this flow.""" + if obj.image_config: + return ImageConfigurationSerializer(obj.image_config).data + return None \ No newline at end of file diff --git a/chatbot/serializer/media_serializer.py b/chatbot/serializer/media_serializer.py new file mode 100644 index 0000000..1eb0a48 --- /dev/null +++ b/chatbot/serializer/media_serializer.py @@ -0,0 +1,674 @@ +from rest_framework import serializers +from django.db.models import Q +from chatbot.models import Media, KeyValue, Tag, FileDisplayMode, FileTypeChoices +from chatbot.models.media_models import MediaImage +import ast +import json +import os + +media_base_url = os.getenv("MEDIA_BASE_URL") + +class S3UrlMixin: + def resolve_s3_url(self, obj): + linked_file = obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains="source document" + ).first() + + if linked_file: + return linked_file.get_s3_url() + + if obj.parent: + parent_kv = obj.parent.key_values.filter(key__iregex=r'^document[ _]type$').first() + parent_doc_type = parent_kv.value.lower() if parent_kv and parent_kv.value else None + if parent_doc_type in ["template", "source document"]: + return obj.get_s3_url() + + return obj.get_s3_url() + + def resolve_thumbnail_url(self, obj): + linked_file = obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains="source document" + ).first() + + if linked_file: + if linked_file.thumbnail: + return linked_file.get_thumbnail_s3_url() + return None + + if obj.parent: + parent_kv = obj.parent.key_values.filter(key__iregex=r'^document[ _]type$').first() + parent_doc_type = parent_kv.value.lower() if parent_kv and parent_kv.value else None + if parent_doc_type in ["template", "source document"]: + if obj.thumbnail: + return obj.get_thumbnail_s3_url() + return None + + if obj.thumbnail: + return obj.get_thumbnail_s3_url() + return None + + +class KeyValueSerializer(serializers.ModelSerializer): + value = serializers.SerializerMethodField() + + class Meta: + model = KeyValue + fields = ['id', 'key', 'value'] + + def get_value(self, obj): + value = obj.value + + if isinstance(value, str) and value.strip().startswith('[') and value.strip().endswith(']'): + try: + parsed_value = ast.literal_eval(value) + if isinstance(parsed_value, list): + return parsed_value + except (ValueError, SyntaxError): + try: + parsed_value = json.loads(value) + if isinstance(parsed_value, list): + return parsed_value + except (json.JSONDecodeError, TypeError): + pass + return value + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ['id', 'name', 'status', 'source_type', 'description'] + + +class MediaImageSerializer(serializers.ModelSerializer): + file_url = serializers.SerializerMethodField() + + class Meta: + model = MediaImage + fields = ['id', 'name', 'media_type', 'page', 'width', 'height', 'file_url', 'created_at'] + + def get_file_url(self, obj): + if obj.file: + return obj.file.url + return None + + +class MediaSearchResultSerializer(serializers.Serializer, S3UrlMixin): + + def to_representation(self, instance): + metadata = instance.get('metadata', {}) + + url = metadata.get('url', '') + company = metadata.get('company', '') + created_at = metadata.get('created_at', '') + updated_at = metadata.get('updated_at', '') + priority = metadata.get('priority', 'P1') + + source_id = instance.get('source_id', '') + + try: + media_id = int(source_id) if source_id else None + except (ValueError, TypeError): + media_id = source_id + + title = metadata.get('TITLE', instance.get('title', '')) + + tags = metadata.get('tags', []) + + document_type = None + for key in ['DOCUMENT_TYPE', 'document_type', 'Document Type', 'DOCUMENT TYPE']: + if key in metadata: + document_type = metadata[key] + break + + file_size = None + organization_url = None + org_logo = None + key_entities = None + display_mode = None + display_mode_display = None + description = None + db_priority = None + db_media_type = None + db_media_type_display = None + thumbnail_url = None + view_count = 0 + download_count = 0 + + if media_id: + try: + from chatbot.models.media_models import Media + media_obj = Media.objects.select_related('organization').prefetch_related( + 'key_values', + 'subdocuments', + 'subdocuments__key_values' + ).only( + 'id', 'file', 'media_type', 'organization__url', 'organization__logo', 'display_mode', + 'description', 'thumbnail', 'priority' + ).get(id=media_id) + + source_child = media_obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains='source document' + ).first() + + if source_child: + db_media_type = source_child.media_type + db_media_type_display = source_child.get_media_type_display() + else: + db_media_type = media_obj.media_type + db_media_type_display = media_obj.get_media_type_display() + + if media_obj.file: + file_size = getattr(media_obj.file, "size", None) + + thumbnail_url = self.resolve_thumbnail_url(media_obj) + + if media_obj.organization: + organization_url = media_obj.organization.url + + if media_obj.organization.logo: + org_logo = media_obj.organization.get_public_url() + + key_entities_kv = media_obj.key_values.filter(key__iexact='KEY ENTITIES').first() + if key_entities_kv: + key_entities = key_entities_kv.value + + display_mode = media_obj.display_mode + display_mode_display = media_obj.get_display_mode_display() + + description = media_obj.description + + db_priority = media_obj.priority + + view_count = media_obj.view_count if media_obj.view_count else 0 + download_count = media_obj.download_count if media_obj.download_count else 0 + + except Exception as e: + file_size = metadata.get('file_size', None) + organization_url = metadata.get('organization_url', None) + + if key_entities is None: + for key in ['KEY ENTITIES', 'key_entities', 'Key Entities', 'KEY_ENTITIES', 'keyEntities']: + if key in metadata: + key_entities = metadata[key] + break + + if db_media_type is None: + metadata_file_type = metadata.get('type', '') + if metadata_file_type: + db_media_type = metadata_file_type + db_media_type_display = self._get_media_type_display(metadata_file_type) + else: + db_media_type = '' + db_media_type_display = '' + + final_description = description if description is not None else instance.get('summary', '') + final_priority = db_priority if db_priority is not None else priority + + return { + 'id': media_id, + 'name': title, + 'description': final_description, + 'priority': final_priority, + 'priority_display': final_priority, + 'media_type': db_media_type, + 'media_type_display': db_media_type_display, + 'created_at': created_at, + 'updated_at': updated_at, + 's3_url': url, + 'thumbnail_url': thumbnail_url, + 'file': url, + 'tag_names': tags, + 'title': title, + 'organization': company, + 'document_type': document_type, + 'key_entities': key_entities, + 'file_size': file_size, + 'organization_url': organization_url, + 'org_logo': org_logo, + 'display_mode': display_mode, + 'display_mode_display': display_mode_display, + 'vector_id': instance.get('id'), + 'score': instance.get('score', 0), + 'field_scores': instance.get('field_scores', {}), + 'view_count': view_count, + 'download_count': download_count, + } + + def _get_media_type_display(self, file_type): + if not file_type: + return '' + + file_type_lower = file_type.lower().strip() + + mime_type = FileTypeChoices.get_mime_from_extension(file_type_lower) + if mime_type: + for choice_value, choice_display in FileTypeChoices.choices: + if choice_value == mime_type: + return choice_display + + for choice_value, choice_display in FileTypeChoices.choices: + if choice_value == file_type_lower: + return choice_display + + additional_extension_to_display = { + 'ppt': 'PPT', + 'pptx': 'PPTX', + 'jpeg': 'JPEG', + 'jpg': 'JPEG', + 'png': 'PNG', + 'gif': 'GIF', + 'mp4': 'MP4', + 'mp3': 'MP3', + 'html': 'HTML', + 'htm': 'HTML', + 'xml': 'XML', + 'json': 'JSON', + 'zip': 'ZIP', + 'rar': 'RAR', + 'tar': 'TAR', + 'gz': 'GZ', + '7z': '7Z', + } + + if file_type_lower in additional_extension_to_display: + return additional_extension_to_display[file_type_lower] + + if file_type_lower.startswith('.'): + clean_ext = file_type_lower[1:] + if clean_ext in additional_extension_to_display: + return additional_extension_to_display[clean_ext] + + additional_mime_to_display = { + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX', + 'application/vnd.ms-powerpoint': 'PPT', + 'text/html': 'HTML', + 'text/xml': 'XML', + 'application/xml': 'XML', + 'application/json': 'JSON', + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'image/gif': 'GIF', + 'image/bmp': 'BMP', + 'image/svg+xml': 'SVG', + 'video/mp4': 'MP4', + 'video/mpeg': 'MPEG', + 'video/quicktime': 'MOV', + 'video/x-msvideo': 'AVI', + 'audio/mpeg': 'MP3', + 'audio/mp3': 'MP3', + 'audio/wav': 'WAV', + 'audio/x-wav': 'WAV', + 'application/zip': 'ZIP', + 'application/x-rar-compressed': 'RAR', + 'application/x-tar': 'TAR', + 'application/gzip': 'GZ', + 'application/x-7z-compressed': '7Z', + } + + if file_type_lower in additional_mime_to_display: + return additional_mime_to_display[file_type_lower] + + if file_type.isupper() and len(file_type) <= 5: + return file_type + + return file_type.upper() if file_type else '' + + +class MediaListSerializer(serializers.ModelSerializer, S3UrlMixin): + s3_url = serializers.SerializerMethodField() + file = serializers.SerializerMethodField() + tag_names = serializers.SerializerMethodField() + media_type = serializers.SerializerMethodField() + media_type_display = serializers.SerializerMethodField() + priority_display = serializers.CharField(source='get_priority_display', read_only=True) + display_mode_display = serializers.CharField(source='get_display_mode_display', read_only=True) + title = serializers.SerializerMethodField() + organization = serializers.SerializerMethodField() + organization_url = serializers.SerializerMethodField() + org_logo = serializers.SerializerMethodField() + document_type = serializers.SerializerMethodField() + key_entities = serializers.SerializerMethodField() + file_size = serializers.SerializerMethodField() + + keyword_coverage = serializers.IntegerField(read_only=True) + total_matching_fields = serializers.IntegerField(read_only=True) + avg_relevance_score = serializers.FloatField(read_only=True) + max_similarity = serializers.FloatField(read_only=True) + match_reason = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() + + class Meta: + model = Media + fields = [ + 'id', 'name', 'description', 'priority', 'priority_display', + 'media_type', 'media_type_display', 'created_at', 'updated_at', + 's3_url', 'file', 'tag_names', 'title', 'organization', + 'document_type', 'key_entities', 'file_size', 'organization_url', 'org_logo', + 'display_mode', 'display_mode_display', + 'keyword_coverage', 'total_matching_fields', 'avg_relevance_score', 'max_similarity', + 'match_reason', 'thumbnail_url' + ] + + def get_match_reason(self, obj): + request = self.context.get('request') + similarity_threshold = 0.3 + if request: + similarity_threshold = float(request.query_params.get('similarity_threshold', 0.3)) + + if getattr(obj, "exact_title_match_flag", 0) == 1: + return "Exact title match found." + + if getattr(obj, "trigram_match", 0) == 1: + max_sim = getattr(obj, "max_similarity", 0) + if max_sim >= similarity_threshold: + return f"Fuzzy string similarity match found (similarity: {max_sim:.2f}, threshold: {similarity_threshold})." + + if getattr(obj, "icontains_match", 0) == 1: + return "Direct text match found in one or more fields." + + if getattr(obj, "keyword_coverage", 0) > 0: + return "This result matched your search keywords." + + if getattr(obj, "max_similarity", 0) > 0: + max_sim = getattr(obj, "max_similarity", 0) + return f"Fuzzy string similarity found (similarity: {max_sim:.2f})." + + return "Match found through search criteria." + + def get_s3_url(self, obj): + return self.resolve_s3_url(obj) + + def get_thumbnail_url(self, obj): + return self.resolve_thumbnail_url(obj) + + def get_file(self, obj): + return obj.get_s3_url() if hasattr(obj, 'get_s3_url') else None + + def get_tag_names(self, obj): + return list(obj.tags.values_list("name", flat=True)) + + def get_metadata_field(self, obj, key_name): + kv = obj.key_values.filter(key__iexact=key_name).first() + return kv.value if kv else None + + def get_title(self, obj): + return self.get_metadata_field(obj, 'TITLE') + + def get_organization(self, obj): + if obj.organization: + return obj.organization.name + return None + + def get_organization_url(self, obj): + if obj.organization: + return obj.organization.url + return None + + def get_org_logo(self, obj): + if obj.organization and obj.organization.logo: + return obj.organization.get_public_url() + return None + + def get_document_type(self, obj): + document_type = obj.key_values.filter(key__iregex=r'^document[ _]type$').first() + return document_type.value if document_type else None + + def get_key_entities(self, obj): + return self.get_metadata_field(obj, 'KEY ENTITIES') + + def get_file_size(self, obj): + return getattr(obj.file, "size", None) if obj.file else None + + def get_media_type(self, obj): + if hasattr(obj, "overridden_media_type"): + return obj.overridden_media_type + + source_child = obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains='source document' + ).first() + + if source_child: + return source_child.media_type + + return obj.media_type + + def get_media_type_display(self, obj): + if hasattr(obj, "overridden_media_type_display"): + return obj.overridden_media_type_display + + source_child = obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains='source document' + ).first() + + if source_child: + return source_child.get_media_type_display() + + return obj.get_media_type_display() + +class MediaDetailSerializer(serializers.ModelSerializer, S3UrlMixin): + s3_url = serializers.SerializerMethodField() + file = serializers.SerializerMethodField() + tags = TagSerializer(many=True, read_only=True) + key_values = serializers.SerializerMethodField() + images = MediaImageSerializer(many=True, read_only=True) + parent_info = serializers.SerializerMethodField() + children = serializers.SerializerMethodField() + media_type = serializers.SerializerMethodField() + media_type_display = serializers.SerializerMethodField() + priority_display = serializers.CharField(source='get_priority_display', read_only=True) + display_mode_display = serializers.CharField(source='get_display_mode_display', read_only=True) + title = serializers.SerializerMethodField() + organization = serializers.SerializerMethodField() + org_logo = serializers.SerializerMethodField() + document_type = serializers.SerializerMethodField() + key_entities = serializers.SerializerMethodField() + file_size = serializers.SerializerMethodField() + size = serializers.SerializerMethodField() + + class Meta: + model = Media + fields = [ + 'id', 'name', 'description', 'priority', 'priority_display', + 'media_type', 'media_type_display', 'extracted_text', + 'file', 'url', 'company_bot', + 'parent', 'parent_info', 'created_at', 'updated_at', + 's3_url', 'tags', 'title', 'organization', 'org_logo', 'document_type', + 'key_entities', 'key_values', 'images', 'children', + 'file_size', 'size', 'display_mode', 'display_mode_display', + ] + + def get_s3_url(self, obj): + return self.resolve_s3_url(obj) + + def get_file(self, obj): + return obj.get_s3_url() if hasattr(obj, 'get_s3_url') else None + + def get_key_values(self, obj): + basic_info = [] + + title = obj.key_values.filter(key__iexact='TITLE').first() + organization_name = None + if obj.organization: + organization_name = obj.organization.name + + geography = obj.key_values.filter(key__iexact='GEOGRAPHY').first() + document_type = obj.key_values.filter(key__iregex=r'^document[ _]type$').first() + + if title and title.value: + basic_info.append(f"
      Title: {title.value}
      ") + + if organization_name: + organization_url = obj.organization.url if obj.organization and obj.organization.url else "#" + basic_info.append( + f'
      Organization: {organization_name}
      ') + if geography and geography.value: + basic_info.append(f"
      Geography: {geography.value}
      ") + + if document_type and document_type.value: + basic_info.append(f"
      Document Type: {document_type.value}
      ") + + filtered_kvs = obj.key_values.exclude( + Q(key__in=['TITLE', 'ORGANIZATION', 'KEY ENTITIES', 'GEOGRAPHY', + 'ORIGINAL_FILE_URL', 'FOUND_IN_DOCUMENT', 'DOCUMENT TYPE REASON']) | + Q(key__iregex=r'^document[ _]type$') | + Q(key__isnull=True, value__isnull=True) | + Q(key='', value='') + ) + + key_values_data = KeyValueSerializer(filtered_kvs, many=True).data + + if basic_info: + basic_info_kv = { + 'id': None, + 'key': 'Basic Information', + 'value': basic_info + } + key_values_data.insert(0, basic_info_kv) + + if not filtered_kvs.exists() and not basic_info: + metadata_kvs = obj.key_values.filter( + Q(key__in=['TITLE', 'KEY ENTITIES', 'GEOGRAPHY']) | + Q(key__iregex=r'^document[ _]type$') + ).exclude( + Q(key__isnull=True, value__isnull=True) | + Q(key='', value='') + ) + key_values_data = KeyValueSerializer(metadata_kvs, many=True).data + + if organization_name: + org_kv = { + 'id': None, + 'key': 'ORGANIZATION', + 'value': organization_name + } + key_values_data.append(org_kv) + + if obj.tags.exists(): + tag_names = list(obj.tags.values_list("name", flat=True)) + tags_classification_kv = { + 'id': None, + 'key': 'Tags for Classification', + 'value': tag_names + } + key_values_data.append(tags_classification_kv) + + references_html = self._get_references_and_associated_documents(obj) + if references_html: + references_kv = { + 'id': None, + 'key': 'References and Associated Documents', + 'value': references_html + } + key_values_data.append(references_kv) + + return key_values_data + + def _get_references_and_associated_documents(self, obj): + references_html = [] + children = obj.subdocuments.all() + + for child in children: + child_doc_type_kv = child.key_values.filter(key__iregex=r'^document[ _]type$').first() + is_source_doc = False + + if child_doc_type_kv and child_doc_type_kv.value: + is_source_doc = 'source document' in child_doc_type_kv.value.lower() + + if is_source_doc: + if child.subdocuments.filter(display_mode=FileDisplayMode.VISIBLE).exists(): + grandchildren = child.subdocuments.filter(display_mode=FileDisplayMode.VISIBLE) + for grandchild in grandchildren: + grandchild_title_kv = grandchild.key_values.filter(key__iexact='TITLE').first() + grandchild_title = grandchild_title_kv.value if grandchild_title_kv and grandchild_title_kv.value else grandchild.name + + references_html.append( + f'
      {grandchild_title}
      ' + ) + else: + child_title_kv = child.key_values.filter(key__iexact='TITLE').first() + child_title = child_title_kv.value if child_title_kv and child_title_kv.value else child.name + + references_html.append( + f'
      {child_title}
      ' + ) + + return references_html + + def get_parent_info(self, obj): + if obj.parent: + return { + 'id': obj.parent.id, + 'name': obj.parent.name, + 'media_type': obj.parent.media_type + } + return None + + def get_children(self, obj): + children = obj.subdocuments.all() + return MediaListSerializer(children, many=True).data if children.exists() else [] + + def get_metadata_field(self, obj, key_name): + kv = obj.key_values.filter(key__iexact=key_name).first() + return kv.value if kv else None + + def get_title(self, obj): + return self.get_metadata_field(obj, 'TITLE') + + def get_organization(self, obj): + if obj.organization: + return obj.organization.name + return None + + def get_org_logo(self, obj): + if obj.organization and obj.organization.logo: + return obj.organization.get_public_url() + return None + + def get_document_type(self, obj): + document_type = obj.key_values.filter(key__iregex=r'^document[ _]type$').first() + return document_type.value if document_type else None + + def get_key_entities(self, obj): + return self.get_metadata_field(obj, 'KEY ENTITIES') + + def get_file_size(self, obj): + try: + if obj.file and hasattr(obj.file, 'size'): + return obj.file.size + return None + except (ValueError, AttributeError): + return None + + def get_size(self, obj): + try: + if obj.file and hasattr(obj.file, 'size'): + size = obj.file.size + if size is None: + return None + + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} PB" + return None + except (ValueError, AttributeError): + return None + + def get_media_type(self, obj): + return getattr(obj, "overridden_media_type", obj.media_type) + + def get_media_type_display(self, obj): + if hasattr(obj, "overridden_media_type_display"): + return obj.overridden_media_type_display + return obj.get_media_type_display() diff --git a/chatbot/serializer/profile_serializer.py b/chatbot/serializer/profile_serializer.py new file mode 100644 index 0000000..664230c --- /dev/null +++ b/chatbot/serializer/profile_serializer.py @@ -0,0 +1,89 @@ +from rest_framework import serializers +from chatbot.models.media_models import ProfileMedia +from chatbot.models.profile_models import Profile +from chatbot.models.company_models import CompanyChat +from chatbot.models.geo_models import ProfileAddress +from chatbot.serializer.company_serializer import CompanySerializer + + +class ProfileAddressSerializer(serializers.ModelSerializer): + + class Meta: + model = ProfileAddress + fields = '__all__' + extra_kwargs = {'profile': {'required': False}} + + +class ProfileMediaSerializer(serializers.ModelSerializer): + public_url = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ProfileMedia + fields = '__all__' + + def get_public_url(self, obj): + return obj.get_public_url() + + +class ProfileSerializer(serializers.ModelSerializer): + company = CompanySerializer(read_only=True) + profile_address = ProfileAddressSerializer(many=True, required=False) + profile_media = ProfileMediaSerializer(many=True, required=False) + + class Meta: + model = Profile + exclude = ['password', ] + + def list(self, request, *args, **kwargs): + print("GET request received") + return super().list(request, *args, **kwargs) + + def create(self, validated_data): + profile_address_data = validated_data.pop('profile_address', None) + profile_media_data = validated_data.pop('profile_media', None) + + profile = Profile.objects.create(**validated_data) + if profile_address_data: + for address_data in profile_address_data: + ProfileAddress.objects.create(profile=profile, **address_data) + + if profile_media_data: + for profile_media in profile_media_data: + ProfileMedia.objects.create(profile=profile, **profile_media) + + return profile + + def update(self, instance, validated_data): + profile_address_data = validated_data.pop('profile_address', []) + profile_media_data = validated_data.pop('profile_media', []) + + for field_name, value in validated_data.items(): + setattr(instance, field_name, value) + + # Update or create ProfileAddress instances + for address_data in profile_address_data: + profile_address = ProfileAddress.objects.filter(profile=instance) + if len(profile_address) > 0: + profile_address = profile_address[0] + for field_name, value in address_data.items(): + setattr(profile_address, field_name, value) + profile_address.save() + else: + ProfileAddress.objects.create(profile=instance, **address_data) + + # Update or create ProfileMedia instances + for media_data in profile_media_data: + media_instance, _ = ProfileMedia.objects.update_or_create( + profile=instance, id=media_data.get('id'), defaults=media_data + ) + + instance.save() + return instance + +class CompanyChatSerializer(serializers.ModelSerializer): + sender = ProfileSerializer(read_only=True) + receiver = ProfileSerializer(read_only=True) + + class Meta: + model = CompanyChat + fields = '__all__' diff --git a/chatbot/serializer/project_serializer.py b/chatbot/serializer/project_serializer.py new file mode 100644 index 0000000..d14952c --- /dev/null +++ b/chatbot/serializer/project_serializer.py @@ -0,0 +1,51 @@ +import json + +from rest_framework import serializers + +from chatbot.serializer.profile_serializer import ProfileSerializer +from chatbot.serializer.story_serializer import StoryRetrieveSerializer +from shikshalokam.models.project_models import Project, Task, Evidence, LearningResources +from shikshalokam.models.template_models import ProjectTemplate + + +class ProjectTemplateSerializer(serializers.ModelSerializer): + """Serializer for ProjectTemplate model""" + class Meta: + model = ProjectTemplate + fields = '__all__' + + +class TaskSerializer(serializers.ModelSerializer): + """Serializer for Task model""" + class Meta: + model = Task + fields = '__all__' + + +class EvidenceSerializer(serializers.ModelSerializer): + """Serializer for Evidence model""" + class Meta: + model = Evidence + fields = '__all__' + + +class LearningResourceSerializer(serializers.ModelSerializer): + """Serializer for LearningResources model""" + class Meta: + model = LearningResources + fields = '__all__' + + +class ProjectSerializer(serializers.ModelSerializer): + """Serializer for Project model""" + story = StoryRetrieveSerializer(read_only=True) + project_template = ProjectTemplateSerializer(read_only=True) + author = ProfileSerializer(read_only=True) + task = TaskSerializer(many=True, read_only=True) + evidence = EvidenceSerializer(many=True, read_only=True) + learning_resource = LearningResourceSerializer(many=True, read_only=True) + + class Meta: + model = Project + fields = '__all__' + diff --git a/chatbot/serializer/story_serializer.py b/chatbot/serializer/story_serializer.py new file mode 100644 index 0000000..83ad6c6 --- /dev/null +++ b/chatbot/serializer/story_serializer.py @@ -0,0 +1,61 @@ +from rest_framework import serializers +from chatbot.models import Story, StoryMedia, StoryTranslation +from chatbot.serializer.profile_serializer import ProfileSerializer +from chatbot.utils.story_utils.base.translation_mixins import TranslationMixin + + +class StoryMediaCreateSerializer(serializers.ModelSerializer): + public_url = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = StoryMedia + exclude = ('base64_str', ) + + def get_public_url(self, obj): + return obj.get_public_url() + + +class StoryMediaRetrieveSerializer(serializers.ModelSerializer): + public_url = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = StoryMedia + fields = '__all__' + + def get_public_url(self, obj): + return obj.get_public_url() + + +class StoryCreateSerializer(serializers.ModelSerializer): + story_media = StoryMediaCreateSerializer(many=True, read_only=True) + + class Meta: + model = Story + exclude = ('formatted_content', ) + + +class StoryRetrieveSerializer(TranslationMixin, serializers.ModelSerializer): + story_media = StoryMediaRetrieveSerializer(many=True, read_only=True) + + def to_representation(self, instance): + """Override to return translated content based on language""" + data = super().to_representation(instance) + return self.apply_translation(data, instance) + + class Meta: + model = Story + fields = '__all__' + + +class StoryFullSerializer(TranslationMixin, serializers.ModelSerializer): + story_media = StoryMediaCreateSerializer(many=True, read_only=True) + author = ProfileSerializer(read_only=True) + + def to_representation(self, instance): + """Override to return translated content based on language""" + data = super().to_representation(instance) + return self.apply_translation(data, instance) + + class Meta: + model = Story + fields = '__all__' diff --git a/chatbot/services/__init__.py b/chatbot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/core/__init__.py b/chatbot/services/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/core/base_service.py b/chatbot/services/core/base_service.py new file mode 100644 index 0000000..713dced --- /dev/null +++ b/chatbot/services/core/base_service.py @@ -0,0 +1,65 @@ +from chatbot.models import CompanyChat, Profile, CompanyBot, ChatSession, BotVernacular +import logging + +logger = logging.getLogger('django') + + +class BaseChatService: + """Common service for shared database operations and utilities""" + + @staticmethod + def get_session_data(session_id, profile_id, bot_route): + """Retrieve common session data""" + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + chat_session = ChatSession.objects.filter(session=session_id).first() + profile = Profile.objects.filter(id=profile_id).first() + + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route=bot_route) + else: + company_bot = CompanyBot.objects.get(route=bot_route) + + return { + 'company_chats': company_chats, + 'chat_session': chat_session, + 'profile': profile, + 'company_bot': company_bot + } + + @staticmethod + def get_bot_vernacular_and_intro(company_bot, profile): + """Handle bot vernacular and intro message logic""" + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot).first() + intro_mssg = None + + if bot_vernacular: + if profile and profile.first_name: + intro_mssg = bot_vernacular.introductory_message + # Insert first name into intro message + first_word = intro_mssg.split(" ")[0] + remaining_message = " ".join(intro_mssg.split(" ")[1:]) + intro_mssg = f"{first_word} {profile.first_name}, {remaining_message}" + else: + intro_mssg = getattr(bot_vernacular, 'alt_introductory_message', + bot_vernacular.introductory_message) + + return bot_vernacular, intro_mssg + + @staticmethod + def get_user_profile_info(profile): + """Extract user profile information""" + if not profile or not profile.first_name: + return None + + profile_addresses = profile.profile_address.all().first() + address_components = [ + profile_addresses.district if profile_addresses and profile_addresses.district else "", + profile_addresses.block if profile_addresses and profile_addresses.block else "", + profile_addresses.state if profile_addresses and profile_addresses.state else "" + ] + address_string = ", ".join(filter(None, address_components)) + + return { + "first_name": profile.first_name, + "user_location": address_string + } diff --git a/chatbot/services/core/bot_service_factory.py b/chatbot/services/core/bot_service_factory.py new file mode 100644 index 0000000..8c14fad --- /dev/null +++ b/chatbot/services/core/bot_service_factory.py @@ -0,0 +1,28 @@ +from chatbot.services.strategies.common_strategy import CommonBotStrategy +from chatbot.services.strategies.guest_discussion import GuestDiscussionBotStrategy +from chatbot.services.strategies.guided import GuidedGuestBotStrategy +from chatbot.services.strategies.oneshot import OneShotBotStrategy + + +class BotServiceFactory: + """Factory to create appropriate bot strategy""" + + _strategies = { + 'oneshot': OneShotBotStrategy, + 'guided_guest': GuidedGuestBotStrategy, + 'guest_discussion': GuestDiscussionBotStrategy, + 'common': CommonBotStrategy + } + + @classmethod + def create_bot_service(cls, bot_type, route=None, extra_params=None, **kwargs): + """Create bot service based on type and optional route""" + strategy_class = cls._strategies.get(bot_type) + if not strategy_class: + raise ValueError(f"Unknown bot type: {bot_type}") + return strategy_class(route=route, extra_params=extra_params, **kwargs) + + @classmethod + def register_strategy(cls, bot_type, strategy_class): + """Register new bot strategy""" + cls._strategies[bot_type] = strategy_class diff --git a/chatbot/services/core/message_handler.py b/chatbot/services/core/message_handler.py new file mode 100644 index 0000000..4435895 --- /dev/null +++ b/chatbot/services/core/message_handler.py @@ -0,0 +1,26 @@ +from chatbot.models import CompanyChat +from chatbot.utils.chat_utils import get_guided_chat + + +class MessageHandler: + """Handle message preparation and filtering""" + + @staticmethod + def prepare_messages(company_bot, company_chats, intro_mssg, other_info=None): + """Prepare messages for processing""" + return get_guided_chat( + company_bot=company_bot, + company_chats=company_chats, + intro=intro_mssg, + other_info=other_info + ) + + @staticmethod + def get_filtered_chats(session_id, state_machine, company_chats): + """Get appropriate chats based on state machine settings""" + if state_machine and state_machine.use_stage_chats: + return CompanyChat.objects.filter( + session=session_id, + stage=state_machine.name + ).order_by('created_at') + return company_chats diff --git a/chatbot/services/core/orchestrator.py b/chatbot/services/core/orchestrator.py new file mode 100644 index 0000000..6e6f2df --- /dev/null +++ b/chatbot/services/core/orchestrator.py @@ -0,0 +1,104 @@ +import traceback +import logging +from .base_service import BaseChatService +from .prompt_builder import PromptBuilder +from .message_handler import MessageHandler +from chatbot.celery_tasks.handle_message import translate_and_send_message + +logger = logging.getLogger('django') + + +class ChatOrchestrator: + """Main orchestrator for chat processing""" + + def __init__(self, bot_strategy): + self.bot_strategy = bot_strategy + self.base_service = BaseChatService() + self.prompt_builder = PromptBuilder() + self.message_handler = MessageHandler() + + def process_chat_request(self, channel_name, session_id, profile_id, language): + """Main processing method""" + try: + # Get session data + session_data = self.base_service.get_session_data( + session_id=session_id, profile_id=profile_id, bot_route=self.bot_strategy.get_route() + ) + + # Get bot vernacular and intro + bot_vernacular, intro_mssg = self.base_service.get_bot_vernacular_and_intro( + company_bot=session_data['company_bot'], profile=session_data['profile'] + ) + + # Get user profile info (for one-shot bots) + other_info = self.base_service.get_user_profile_info(profile=session_data['profile']) + # Prepare initial messages + messages = self.message_handler.prepare_messages( + company_bot=session_data['company_bot'], company_chats=session_data['company_chats'], + intro_mssg=intro_mssg, other_info=other_info + ) + + # Process session based on strategy + session_result = self.bot_strategy.process_session( + session_data, intro_mssg=intro_mssg, other_info=other_info, messages=messages + ) + + if session_result.get('error'): + return self._handle_error_response( + error_msg=session_result['error'], channel_name=channel_name, language=language, + chat_session=session_data['chat_session'], company_bot=session_data['company_bot'] + ) + + state_machine = session_result.get('state_machine', None) + + # Get filtered chats + temp_company_chats = self.message_handler.get_filtered_chats( + session_id=session_id, state_machine=state_machine, + company_chats=session_data['company_chats'] + ) + + # Prepare temp messages + temp_messages = self.message_handler.prepare_messages( + company_bot=session_data['company_bot'], company_chats=temp_company_chats, + intro_mssg=intro_mssg, other_info=other_info + ) + + # Build prompt + prompt_to_use = self.prompt_builder.build_system_prompt( + company_bot=session_data['company_bot'], state_machine=state_machine + ) + + # Get response using strategy + response_params = { + 'system_prompt': prompt_to_use, + 'messages': messages, + 'company_bot': session_data['company_bot'], + 'session_id': session_id, + 'channel_name': channel_name, + 'language': language, + 'profile_id': profile_id, + 'temp_messages': temp_messages, + 'intro_mssg': intro_mssg, + } + + # Add strategy-specific parameters + if hasattr(self.bot_strategy, 'get_route') and 'oneshot' in self.bot_strategy.get_route(): + response_params['remaining_stages'] = session_result.get('remaining_stages', []) + + response = self.bot_strategy.get_response(**response_params) + + logger.info('Bot response: %s', response) + return response + + except Exception as e: + logger.error('Error in chat processing: %s', e, exc_info=True) + traceback.print_exc() + return None + + def _handle_error_response(self, error_msg, channel_name, language, chat_session, company_bot): + """Handle error responses""" + logger.info(f"Sending error message: {error_msg}") + return translate_and_send_message( + accumulated_message=error_msg, current_channel_name=channel_name, finish_reason="stop", + current_step_number=chat_session.current_step, route=language, company_bot=company_bot + ) diff --git a/chatbot/services/core/prompt_builder.py b/chatbot/services/core/prompt_builder.py new file mode 100644 index 0000000..0befa6b --- /dev/null +++ b/chatbot/services/core/prompt_builder.py @@ -0,0 +1,42 @@ +from chatbot.models import LLMProvider + + +class PromptBuilder: + """Centralized prompt building logic""" + + @staticmethod + def build_system_prompt(company_bot, state_machine=None): + """Build system prompt based on provider type""" + + system_parts = [company_bot.context.strip()] + + if state_machine and state_machine.context: + system_parts.append(state_machine.context.strip()) + + if state_machine and state_machine.completion_criteria: + system_parts.append(f"Completion Criteria:\n{state_machine.completion_criteria.strip()}") + + tool_context = "" + if (state_machine and + hasattr(state_machine, 'tool_context') and + state_machine.tool_context and + state_machine.tool_context.strip()): + tool_context = None + elif company_bot.tool_context and company_bot.tool_context.strip(): + tool_context = company_bot.tool_context.strip() + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + result = [{'text': system_parts[0]}] + if len(system_parts) > 1: + result.append({'text': "\n\n".join(system_parts[1:])}) + if tool_context: + result.append({'text': tool_context}) + return result + + elif company_bot.provider == LLMProvider.OPENAI: + return [{ + 'role': 'system', + 'content': "\n\n".join(system_parts) + }] + + return [] diff --git a/chatbot/services/free_flow/__init__.py b/chatbot/services/free_flow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/free_flow/free_flow_service.py b/chatbot/services/free_flow/free_flow_service.py new file mode 100644 index 0000000..72600f8 --- /dev/null +++ b/chatbot/services/free_flow/free_flow_service.py @@ -0,0 +1,176 @@ +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +from chatbot.llm_models.llm_script import handle_openai_response_api +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.models import ChatStatus, Profile, CompanyBot, CompanyChat +from chatbot.utils.chat_utils import get_guided_chat +import logging +import json + +channel_layer = get_channel_layer() +logger = logging.getLogger('django') + + +class FreeFlowService: + """ + Service for handling free-flow streaming responses. + """ + + def process_and_stream(self, channel_name, session_id, profile_id, route, bot_route): + """ + Process user message and stream LLM response back via channel layer. + """ + try: + logger.info(f"Processing free-flow for session {session_id}, channel {channel_name}") + + # 1. Fetch data from database (sync is OK in Celery) + profile = None + if profile_id: + profile = Profile.objects.filter(id=profile_id).first() + + # Get company bot configuration + if profile: + company_bot = CompanyBot.objects.filter(company=profile.company, route=bot_route).first() + else: + company_bot = CompanyBot.objects.filter(route=bot_route).first() + + if not company_bot: + logger.error(f"Company bot not found for route: {bot_route}") + self._send_error(channel_name, "Bot configuration not found") + return + + # Get conversation history + all_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + company_chats = list(all_chats) + + logger.info(f"Fetched {len(company_chats)} chat messages for history") + + # 2. Format messages for OpenAI using get_guided_chat + messages = get_guided_chat( + company_bot=company_bot, + company_chats=company_chats, + intro=None # No intro for free-flow + ) + + # 3. Prepare system prompt + system_prompt = company_bot.context + + # Convert to list format for Responses API + system_prompt = [{'role': 'system', 'content': system_prompt}] + + # 4. Parse tools from company_bot.tool_context + tools = None + if company_bot.tool_context: + try: + tools = json.loads(company_bot.tool_context) + logger.info(f"Loaded tools from company_bot.tool_context: {len(tools) if isinstance(tools, list) else 'object'}") + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Error parsing company_bot.tool_context: {e}") + + # 5. Stream response from OpenAI Responses API + accumulated_response = "" + finish_reason = None + + stream = company_bot.stream if hasattr(company_bot, 'stream') else True + + logger.info(f"Starting LLM {'streaming' if stream else 'call'} for session {session_id}") + + # Call LLM synchronously (this is fine in Celery worker) + for chunk_data in handle_openai_response_api( + messages=messages, + system_prompt=system_prompt, + max_token=company_bot.max_token if company_bot.max_token else 2048, + temperature=company_bot.bot_temperature if company_bot.bot_temperature is not None else 0.0, + company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice="auto", + tools=tools, + stream=stream + ): + content = chunk_data.get('content', '') + finish_reason = chunk_data.get('finish_reason') + error = chunk_data.get('error') + extra_content = chunk_data.get('extra_content') + + if error: + logger.error(f'Streaming error: {error}') + self._send_error(channel_name, "Error processing your request") + return + + if content: + accumulated_response += content + + # Send chunk via channel layer to WebSocket (even if content is empty but finish_reason exists) + if content or finish_reason: + self._send_chunk(channel_name, content, finish_reason, extra_content) + + if finish_reason: + logger.info(f"Streaming completed with finish_reason: {finish_reason}") + break + + # 6. Save complete response to database + if accumulated_response: + save_in_company_db( + session_id=session_id, + profile_id=profile_id, + initiated_by='AI', + message=accumulated_response, + chunks=None, + status=ChatStatus.IN_PROGRESS, + stage= None + ) + + logger.info(f'Completed streaming response, length: {len(accumulated_response)} chars') + else: + logger.info(f'No response accumulated for session {session_id}') + + except Exception as e: + logger.error(f'Error in process_and_stream: {e}', exc_info=True) + self._send_error(channel_name, "An error occurred processing your message") + + def _send_chunk(self, channel_name, content, finish_reason, extra_content=None): + """ + Send a chunk via channel layer to the WebSocket. + """ + try: + message_data = { + "type": "chat.message", # → calls chat_message() in consumer + "text": { + "msg": content, + "source": "bot", + "type": "chunk", + "finish_reason": finish_reason + }, + } + + # Add extra_content (like citations) if present + if extra_content: + message_data["text"]["extra_content"] = extra_content + + async_to_sync(channel_layer.send)( + channel_name, + message_data, + ) + except Exception as e: + # Don't crash if WebSocket disconnected - log and continue + logger.info(f"Failed to send chunk to channel {channel_name}: {e}") + + def _send_error(self, channel_name, error_msg): + """ + Send error message via channel layer to the WebSocket. + """ + try: + async_to_sync(channel_layer.send)( + channel_name, + { + "type": "chat.message", + "text": { + "msg": error_msg, + "source": "bot", + "type": "error", + "finish_reason": "error" + }, + }, + ) + except Exception as e: + logger.error(f"Failed to send error to channel {channel_name}: {e}") diff --git a/chatbot/services/i18n_export_service.py b/chatbot/services/i18n_export_service.py new file mode 100644 index 0000000..85b1e54 --- /dev/null +++ b/chatbot/services/i18n_export_service.py @@ -0,0 +1,265 @@ +""" +Service for exporting I18n translations to JSON format. +Prepares translations data for cloud storage upload. +""" +import json +import logging +from io import BytesIO +from typing import Dict, Any, List, Tuple, Optional +from chatbot.models import I18nTag, I18nTranslation +from chatbot.services.storage import StorageFactory, UploadConfig, UploadResult + +logger = logging.getLogger('django') + + +# Predefined list of supported languages +SUPPORTED_LANGUAGES: List[Tuple[str, str]] = [ + ('en', 'English'), + ('hi', 'Hindi'), + ('kn', 'Kannada'), + ('te', 'Telugu'), +] + + +def get_supported_languages() -> List[Tuple[str, str]]: + """ + Returns the list of supported languages for i18n export. + + Returns: + List of tuples containing (language_code, language_name) + """ + return SUPPORTED_LANGUAGES + + +def generate_i18n_json_for_language(language: str) -> Dict[str, Dict[str, Any]]: + """ + Generate JSON structure for all i18n tags and translations for a given language. + + Args: + language: Language code (e.g., 'en', 'hi', 'kn') + + Returns: + Dictionary structured as: + { + "tag_name": { + "variable_name": "translated_value", + ... + }, + ... + } + """ + result: Dict[str, Dict[str, Any]] = {} + + tags = I18nTag.objects.all().order_by('tag_name') + + for tag in tags: + tag_translations = {} + + # Fetch all translations for this tag in the specified language + translations = I18nTranslation.objects.filter( + tag_id=tag, + language=language.lower() + ).order_by('variable_name') + + for translation in translations: + tag_translations[translation.variable_name] = translation.value + + # Only add tag to result if it has translations for this language + if tag_translations: + result[tag.tag_name] = tag_translations + + return result + + +def get_export_filename(language: str) -> str: + """ + Generate the filename for the export JSON file. + Following the S3 naming convention: translations/{language}/translations.json + + Args: + language: Language code (e.g., 'en', 'hi') + + Returns: + Filename string (e.g., 'translations_en.json') + """ + return f"translations_{language}.json" + + +def get_s3_path(language: str) -> str: + """ + Generate the S3 path for the translations file. + + Args: + language: Language code (e.g., 'en', 'hi') + + Returns: + S3 path string (e.g., 'translations/en/translations.json') + """ + return f"translations/{language}/translations.json" + + +def generate_i18n_json_string(language: str, indent: int = 2) -> str: + """ + Generate JSON string for all i18n translations in a given language. + + Args: + language: Language code (e.g., 'en', 'hi') + indent: JSON indentation level (default: 2) + + Returns: + JSON string representation of the translations + """ + data = generate_i18n_json_for_language(language) + return json.dumps(data, ensure_ascii=False, indent=indent) + + +def export_single_language_to_cloud(language: str) -> UploadResult: + """ + Export translations for a single language to cloud storage. + + Args: + language: Language code (e.g., 'en', 'hi') + + Returns: + UploadResult with upload details including public URL + + Raises: + Exception: If upload fails + """ + try: + # Generate JSON content for the language + json_content = generate_i18n_json_string(language) + + # Convert string to bytes + json_bytes = json_content.encode('utf-8') + file_obj = BytesIO(json_bytes) + + # Get storage handler + storage_handler = StorageFactory.get_storage_handler() + + # Create upload configuration + upload_config = UploadConfig( + file_name='translations.json', + file_type='application/json', + folder_structure=f'translations/{language}/', + acl='public-read' + ) + + # Upload to cloud storage + result = storage_handler.upload_file(file_obj, upload_config) + + if result.success: + logger.info(f"Successfully exported {language} translations to: {result.public_url}") + else: + logger.error(f"Failed to export {language} translations: {result.error}") + + return result + + except Exception as e: + error_msg = f"Error exporting {language} translations: {str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + + +def export_all_languages_to_cloud() -> List[Dict[str, Any]]: + """ + Export translations for all supported languages to cloud storage. + Rolls back all uploads if any single upload fails. + + Returns: + List of dictionaries containing language code and public URL for each successful export + + Raises: + Exception: If any upload fails (triggers rollback) + """ + uploaded_files = [] + + try: + storage_handler = StorageFactory.get_storage_handler() + + # Export all languages + for language_code, language_name in get_supported_languages(): + logger.info(f"Exporting {language_name} ({language_code}) translations...") + + result = export_single_language_to_cloud(language_code) + + if not result.success: + raise Exception(f"Failed to export {language_name}: {result.error}") + + uploaded_files.append({ + 'language_code': language_code, + 'language_name': language_name, + 'object_key': result.object_key, + 'public_url': result.public_url + }) + + logger.info(f"Exported {language_name} to: {result.public_url}") + + return uploaded_files + + except Exception as e: + # Rollback: delete all uploaded files + logger.error(f"Export failed, rolling back all uploads: {str(e)}") + + for uploaded in uploaded_files: + try: + storage_handler.delete_file(uploaded['object_key']) + logger.info(f"Rolled back {uploaded['language_name']} export") + except Exception as delete_error: + logger.error(f"Failed to rollback {uploaded['language_name']}: {str(delete_error)}") + + raise Exception(f"Export failed and rolled back: {str(e)}") + + +def export_translations_to_cloud(language: Optional[str] = None) -> Dict[str, Any]: + """ + Export i18n translations to cloud storage. + + Args: + language: Language code to export, or 'all' to export all languages, or None for all + + Returns: + Dictionary with export results: + { + 'success': bool, + 'exports': List of exported files with URLs, + 'error': Optional error message + } + """ + try: + if language == 'all' or language is None: + # Export all languages + exports = export_all_languages_to_cloud() + return { + 'success': True, + 'exports': exports, + 'message': f'Successfully exported {len(exports)} languages' + } + else: + # Export single language + result = export_single_language_to_cloud(language) + + if not result.success: + return { + 'success': False, + 'error': result.error + } + + language_name = dict(get_supported_languages()).get(language, language) + return { + 'success': True, + 'exports': [{ + 'language_code': language, + 'language_name': language_name, + 'object_key': result.object_key, + 'public_url': result.public_url + }], + 'message': f'Successfully exported {language_name}' + } + + except Exception as e: + logger.error(f"Export failed: {str(e)}") + return { + 'success': False, + 'error': str(e) + } diff --git a/chatbot/services/postprocessing/base_postprocessor.py b/chatbot/services/postprocessing/base_postprocessor.py new file mode 100644 index 0000000..cd15bd1 --- /dev/null +++ b/chatbot/services/postprocessing/base_postprocessor.py @@ -0,0 +1,147 @@ +from abc import ABC, abstractmethod +import logging + +logger = logging.getLogger('django') + + +class BasePostprocessor(ABC): + """Base class for postprocessing operations""" + + @abstractmethod + def postprocess(self, state_machine, llm_response, **kwargs): + """Execute postprocessing logic""" + pass + + +class SimplePostprocessor(BasePostprocessor): + """Handles simple prompt-based postprocessing""" + + def postprocess(self, state_machine, llm_response, **kwargs): + """Execute simple postprocessing using postprocess_prompt""" + if not state_machine.postprocess_prompt: + logger.warning(f"No postprocess_prompt defined for state {state_machine.name}") + return None + + # Get LLM handler + from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model + from chatbot.models import LLMProvider + + company_bot = kwargs.get('company_bot') + messages = kwargs.get('messages', []) + + # Add the LLM response to messages for context + enhanced_messages = messages.copy() + if llm_response: + enhanced_messages.append({ + 'role': 'assistant', + 'content': str(llm_response) + }) + + # Build simple prompt with LLM response context + simple_prompt = self._build_simple_prompt( + state_machine.postprocess_prompt, company_bot, llm_response + ) + + try: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + response = handle_bedrock_model( + system_prompt=simple_prompt, + messages=enhanced_messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot + ) + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=simple_prompt, + messages=enhanced_messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + tools=None, + tool_choice='auto', + is_json_response=False + ) + else: + response = None + + logger.info(f"Simple postprocessing response for {state_machine.name}: {response}") + return response + + except Exception as e: + logger.error(f"Error in simple postprocessing: {e}") + return None + + def _build_simple_prompt(self, postprocess_prompt, company_bot, llm_response): + """Build prompt for simple postprocessing""" + enhanced_prompt = f"{postprocess_prompt}\n\nLLM Response to analyze: {llm_response}" + + from chatbot.models import LLMProvider + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': enhanced_prompt}] + else: + return [{'role': 'system', 'content': enhanced_prompt}] + + +class ComplexPostprocessor(BasePostprocessor): + """Handles complex bot-based postprocessing""" + + def postprocess(self, state_machine, llm_response, **kwargs): + """Execute complex postprocessing using postprocess_bot""" + if not state_machine.postprocess_bot: + logger.warning(f"No postprocess_bot defined for state {state_machine.name}") + return None + + try: + # Use the postprocess_bot to analyze the LLM response + from chatbot.services.core.prompt_builder import PromptBuilder + from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model + from chatbot.models import LLMProvider + + postprocess_bot = state_machine.postprocess_bot + messages = kwargs.get('messages', []) + + # Add the LLM response to messages for context + enhanced_messages = messages.copy() + if llm_response: + from chatbot.models import LLMProvider + enhanced_messages.append({ + 'role': 'assistant' if postprocess_bot.provider == LLMProvider.OPENAI else 'assistant', + 'content': str(llm_response) + }) + + # Build prompt using the postprocess bot's context + prompt_builder = PromptBuilder() + + system_prompt = prompt_builder.build_system_prompt(postprocess_bot, None) + + if postprocess_bot.provider == LLMProvider.BEDROCK_CONVERSE: + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=enhanced_messages, + model_name=postprocess_bot.llm_model, + temperature=postprocess_bot.bot_temperature, + max_token=postprocess_bot.max_token, + company_bot=postprocess_bot + ) + elif postprocess_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=system_prompt, + messages=enhanced_messages, + model_name=postprocess_bot.llm_model, + temperature=postprocess_bot.bot_temperature, + max_token=postprocess_bot.max_token, + tools=None, + tool_choice='auto', + is_json_response=False + ) + else: + response = None + + logger.info(f"Complex postprocessing response for {state_machine.name}: {response}") + return response + + except Exception as e: + logger.error(f"Error in complex postprocessing: {e}") + return None diff --git a/chatbot/services/postprocessing/postprocess_output_handlers.py b/chatbot/services/postprocessing/postprocess_output_handlers.py new file mode 100644 index 0000000..e86bf76 --- /dev/null +++ b/chatbot/services/postprocessing/postprocess_output_handlers.py @@ -0,0 +1,89 @@ +import json +from chatbot.models import PostProcessOutputMode +import json_repair +import logging + +logger = logging.getLogger('django') + + +class PostprocessOutputHandler: + """Handles different postprocessing output modes""" + + @staticmethod + def handle_output(output_mode, postprocess_response, llm_response, **kwargs): + """Handle postprocessing output based on mode""" + if output_mode == PostProcessOutputMode.SKIP: + return PostprocessSkipOutputHandler.handle(postprocess_response, llm_response, **kwargs) + else: + return {'action': 'continue', 'skip_next_stage': False} + + +class PostprocessSkipOutputHandler: + """Handles SKIP output mode for postprocessing""" + + @staticmethod + def handle(postprocess_response, llm_response, **kwargs): + """ + Determine if we should skip the next stage based on postprocess response. + :param postprocess_response: Raw string or dict-like response from LLM. + :param llm_response: Original LLM raw response for fallback. + :return: Dict with skip flag. + """ + logger.info(f"[PostprocessSkipOutputHandler] Handling SKIP mode.") + logger.info(f"[PostprocessSkipOutputHandler] postprocess_response type: {type(postprocess_response)}") + logger.info(f"[PostprocessSkipOutputHandler] postprocess_response: {postprocess_response}") + + if not postprocess_response: + logger.info("[PostprocessSkipOutputHandler] No postprocess_response provided. Continue to next stage.") + return {'action': 'continue', 'skip_next_stage': False} + + should_skip = False + + parsed = None + if isinstance(postprocess_response, dict): + logger.info("[PostprocessSkipOutputHandler] postprocess_response is already a dict.") + parsed = postprocess_response + else: + try: + logger.info(f"[PostprocessSkipOutputHandler] Parsed JSON successfully: {parsed}") + parsed = json_repair.repair_json(postprocess_response, return_objects=True) + except json.JSONDecodeError: + logger.error("[PostprocessSkipOutputHandler] Failed to parse JSON. Using fallback text mode.") + parsed = None + + # If it's a dict, only check known keys + if isinstance(parsed, dict): + for key, val in parsed.items(): + logger.info(f"[PostprocessSkipOutputHandler] Checking key='{key}', value={val}") + key_lower = str(key).lower() + # Ignore reasoning-type fields + if any(skip_word in key_lower for skip_word in ["reason", "reasoning", "explanation"]): + logger.info(f"[PostprocessSkipOutputHandler] Skipping check for key '{key}' (reasoning/explanation).") + continue + + if isinstance(val, bool): + logger.info(f"[PostprocessSkipOutputHandler] Boolean value detected: {val}") + if val: # Only skip if true + logger.info(f"[PostprocessSkipOutputHandler] Skip triggered by boolean True in key '{key}'.") + should_skip = True + break + elif isinstance(val, str): + val_stripped = val.strip().lower() + logger.info(f"[PostprocessSkipOutputHandler] String value detected: '{val_stripped}'") + if val_stripped in ["yes", "true", "skip"]: + should_skip = True + logger.info(f"[PostprocessSkipOutputHandler] Skip triggered by string '{val_stripped}' in key '{key}'.") + break + + # If not JSON, fallback to strict keyword match + else: + text = str(postprocess_response).strip().lower() + logger.info(f"[PostprocessSkipOutputHandler] Fallback text mode. Processed text: '{text}'") + # only match whole words to avoid "yes" inside sentences + if text in ["skip", "yes", "true"]: + should_skip = True + logger.info(f"[PostprocessSkipOutputHandler] Skip triggered by fallback text '{text}'.") + + + logger.info(f"[PostprocessSkipOutputHandler] Final decision: skip_next_stage={should_skip}") + return {'action': 'continue', 'skip_next_stage': should_skip} \ No newline at end of file diff --git a/chatbot/services/postprocessing/postprocessing_service.py b/chatbot/services/postprocessing/postprocessing_service.py new file mode 100644 index 0000000..f0cc0af --- /dev/null +++ b/chatbot/services/postprocessing/postprocessing_service.py @@ -0,0 +1,46 @@ +from chatbot.models import PostProcessType +from chatbot.services.postprocessing.base_postprocessor import SimplePostprocessor, ComplexPostprocessor +from chatbot.services.postprocessing.postprocess_output_handlers import PostprocessOutputHandler +import logging + +logger = logging.getLogger('django') + + +class PostprocessingService: + """Main service for handling postprocessing operations""" + + def __init__(self): + self.postprocessors = { + PostProcessType.SIMPLE: SimplePostprocessor(), + PostProcessType.COMPLEX: ComplexPostprocessor() + } + + def execute_postprocessing(self, state_machine, llm_response, **kwargs): + """Execute postprocessing based on state machine configuration""" + # Skip postprocessing if not configured + if state_machine.postprocess_type == PostProcessType.NONE: + return {'action': 'continue', 'skip_next_stage': False} + + # Get appropriate postprocessor + postprocessor = self.postprocessors.get(state_machine.postprocess_type) + if not postprocessor: + logger.warning(f"No postprocessor found for type: {state_machine.postprocess_type}") + return {'action': 'continue', 'skip_next_stage': False} + + # Execute postprocessing + logger.info(f"Executing {state_machine.postprocess_type} postprocessing for state: {state_machine.name}") + postprocess_response = postprocessor.postprocess(state_machine, llm_response, **kwargs) + + # Handle output based on mode + result = PostprocessOutputHandler.handle_output( + state_machine.postprocess_output_mode, + postprocess_response, + llm_response, + **kwargs + ) + + return result + + def register_postprocessor(self, postprocess_type, postprocessor): + """Register a new postprocessor""" + self.postprocessors[postprocess_type] = postprocessor diff --git a/chatbot/services/preprocessing/__init__.py b/chatbot/services/preprocessing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/preprocessing/base_preprocessor.py b/chatbot/services/preprocessing/base_preprocessor.py new file mode 100644 index 0000000..6450076 --- /dev/null +++ b/chatbot/services/preprocessing/base_preprocessor.py @@ -0,0 +1,127 @@ +from abc import ABC, abstractmethod +import logging + +from chatbot.models import LLMProvider + +logger = logging.getLogger('django') + + +class BasePreprocessor(ABC): + """Base class for preprocessing operations""" + + @abstractmethod + def preprocess(self, state_machine, **kwargs): + """Execute preprocessing logic""" + pass + + +class SimplePreprocessor(BasePreprocessor): + """Handles simple prompt-based preprocessing""" + + def preprocess(self, state_machine, **kwargs): + """Execute simple preprocessing using preprocess_prompt""" + if not state_machine.preprocess_prompt: + logger.warning(f"No preprocess_prompt defined for state {state_machine.name}") + return None + + # Get LLM handler + from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model + from chatbot.models import LLMProvider + + company_bot = kwargs.get('company_bot') + messages = kwargs.get('messages', []) + + # Build simple prompt + simple_prompt = self._build_simple_prompt(state_machine.preprocess_prompt, company_bot) + + try: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + response = handle_bedrock_model( + system_prompt=simple_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot + ) + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=simple_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + tools=None, + tool_choice='auto', + is_json_response=False + ) + else: + response = None + + logger.info(f"Simple preprocessing response for {state_machine.name}: {response}") + return response + + except Exception as e: + logger.error(f"Error in simple preprocessing: {e}") + return None + + def _build_simple_prompt(self, preprocess_prompt, company_bot): + """Build prompt for simple preprocessing""" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return [{'text': preprocess_prompt}] + else: + return [{'role': 'system', 'content': preprocess_prompt}] + + +class ComplexPreprocessor(BasePreprocessor): + """Handles complex bot-based preprocessing""" + + def preprocess(self, state_machine, **kwargs): + """Execute complex preprocessing using preprocess_bot""" + if not state_machine.preprocess_bot: + logger.warning(f"No preprocess_bot defined for state {state_machine.name}") + return None + + try: + # Use the preprocess_bot to generate response like normal flow + from chatbot.services.core.prompt_builder import PromptBuilder + from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model + from chatbot.models import LLMProvider + + preprocess_bot = state_machine.preprocess_bot + messages = kwargs.get('messages', []) + + # Build prompt using the preprocess bot's context + prompt_builder = PromptBuilder() + + system_prompt = prompt_builder.build_system_prompt(preprocess_bot, None) + + if preprocess_bot.provider == LLMProvider.BEDROCK_CONVERSE: + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=messages, + model_name=preprocess_bot.llm_model, + temperature=preprocess_bot.bot_temperature, + max_token=preprocess_bot.max_token, + company_bot=preprocess_bot + ) + elif preprocess_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=system_prompt, + messages=messages, + model_name=preprocess_bot.llm_model, + temperature=preprocess_bot.bot_temperature, + max_token=preprocess_bot.max_token, + tools=None, + tool_choice='auto', + is_json_response=False + ) + else: + response = None + + logger.info(f"Complex preprocessing response for {state_machine.name}: {response}") + return response + + except Exception as e: + logger.error(f"Error in complex preprocessing: {e}") + return None diff --git a/chatbot/services/preprocessing/output_handlers.py b/chatbot/services/preprocessing/output_handlers.py new file mode 100644 index 0000000..e66dc24 --- /dev/null +++ b/chatbot/services/preprocessing/output_handlers.py @@ -0,0 +1,145 @@ +import json +import json_repair +from chatbot.models import PreProcessOutputMode, LLMProvider +import logging + +logger = logging.getLogger('django') + + +class PreprocessOutputHandler: + """Handles different preprocessing output modes""" + + @staticmethod + def handle_output(output_mode, preprocess_response, original_prompt, **kwargs): + """Handle preprocessing output based on mode""" + if output_mode == PreProcessOutputMode.SKIP: + return SkipOutputHandler.handle(preprocess_response, **kwargs) + elif output_mode == PreProcessOutputMode.MODIFY_QUESTION: + return ModifyQuestionOutputHandler.handle( + preprocess_response, original_prompt, **kwargs + ) + else: + return {'action': 'continue', 'prompt': original_prompt} + + +class SkipOutputHandler: + """Handles SKIP output mode""" + + @staticmethod + def handle(preprocess_response, **kwargs): + """ + Determine if we should skip the next stage based on preprocess response. + :param preprocess_response: Raw string or dict-like response from LLM. + :return: Dict with skip flag. + """ + logger.info(f"[SkipOutputHandler] Handling SKIP mode.") + logger.info(f"[SkipOutputHandler] preprocess_response type: {type(preprocess_response)}") + logger.info(f"[SkipOutputHandler] preprocess_response: {preprocess_response}") + + if not preprocess_response: + logger.info("[SkipOutputHandler] No preprocess_response provided. Continue to next stage.") + return {'action': 'continue'} + + should_skip = False + + if isinstance(preprocess_response, dict): + logger.info("[SkipOutputHandler] preprocess_response is already a dict.") + parsed = preprocess_response + else: + try: + parsed = json_repair.repair_json(preprocess_response, return_objects=True) + logger.info(f"[SkipOutputHandler] Parsed JSON successfully: {parsed}") + except json.JSONDecodeError: + logger.error("[SkipOutputHandler] Failed to parse JSON. Using fallback text mode.") + parsed = None + + if isinstance(parsed, dict): + for key, val in parsed.items(): + logger.info(f"[SkipOutputHandler] Checking key='{key}', value={val}") + key_lower = str(key).lower() + if any(skip_word in key_lower for skip_word in ["reason", "reasoning", "explanation"]): + logger.info(f"[SkipOutputHandler] Skipping check for key '{key}' (reasoning/explanation).") + continue + + if isinstance(val, bool): + logger.info(f"[SkipOutputHandler] Boolean value detected: {val}") + if val: + logger.info(f"[SkipOutputHandler] Skip triggered by boolean True in key '{key}'.") + should_skip = True + break + elif isinstance(val, str): + val_stripped = val.strip().lower() + logger.info(f"[SkipOutputHandler] String value detected: '{val_stripped}'") + if val_stripped in ["yes", "true", "skip"]: + should_skip = True + logger.info(f"[SkipOutputHandler] Skip triggered by string '{val_stripped}' in key '{key}'.") + break + + else: + text = str(preprocess_response).strip().lower() + logger.info(f"[SkipOutputHandler] Fallback text mode. Processed text: '{text}'") + if text in ["skip", "yes", "true"]: + should_skip = True + logger.info(f"[SkipOutputHandler] Skip triggered by fallback text '{text}'.") + + logger.info(f"[SkipOutputHandler] Final decision: skip={should_skip}") + + if should_skip: + return {'action': 'skip'} + else: + return {'action': 'continue'} + + +class ModifyQuestionOutputHandler: + """Handles MODIFY_QUESTION output mode""" + + @staticmethod + def handle(preprocess_response, original_prompt, **kwargs): + """ + Extract modified_question from preprocessing response. + Falls back to original bot_question if parsing fails. + """ + logger.info(f"[ModifyQuestionOutputHandler] Processing response") + + if not preprocess_response: + logger.info("No preprocessing response, cannot modify question") + return {'action': 'continue', 'prompt': original_prompt} + + modified_question = None + + if isinstance(preprocess_response, dict): + parsed = preprocess_response + else: + try: + parsed = json_repair.repair_json(preprocess_response, return_objects=True) + except Exception as e: + logger.error(f"Failed to parse preprocessing response: {e}") + return {'action': 'continue', 'prompt': original_prompt} + + if isinstance(parsed, dict): + modified_question = parsed.get('modified_question', '').strip() + + if modified_question: + logger.info(f"Successfully extracted modified question: {modified_question[:100]}") + return { + 'action': 'modify_question', + 'modified_bot_question': modified_question, + 'prompt': original_prompt + } + else: + logger.info("No valid modified_question found, will use original") + return {'action': 'continue', 'prompt': original_prompt} + + +class CustomOutputHandler: + """Handles CUSTOM output mode""" + + @staticmethod + def handle(preprocess_response, **kwargs): + """Handle custom preprocessing logic""" + logger.info(f"Custom preprocessing logic called with response: {preprocess_response}") + print(f"CUSTOM PREPROCESSING: {preprocess_response}") + + # For now, just print and continue with original flow + # In the future, this can be extended to call specific custom functions + return {'action': 'continue'} diff --git a/chatbot/services/preprocessing/preprocessing_service.py b/chatbot/services/preprocessing/preprocessing_service.py new file mode 100644 index 0000000..ff9768e --- /dev/null +++ b/chatbot/services/preprocessing/preprocessing_service.py @@ -0,0 +1,43 @@ +from chatbot.models import PreProcessType +from chatbot.services.preprocessing.base_preprocessor import SimplePreprocessor, ComplexPreprocessor +from chatbot.services.preprocessing.output_handlers import PreprocessOutputHandler +import logging + +logger = logging.getLogger('django') + + +class PreprocessingService: + """Main service for handling preprocessing operations""" + + def __init__(self): + self.preprocessors = { + PreProcessType.SIMPLE: SimplePreprocessor(), + PreProcessType.COMPLEX: ComplexPreprocessor() + } + + def execute_preprocessing(self, state_machine, original_prompt, **kwargs): + """Execute preprocessing based on state machine configuration""" + # Skip preprocessing if not configured + if state_machine.preprocess_type == PreProcessType.NONE: + return {'action': 'continue', 'prompt': original_prompt} + + # Get appropriate preprocessor + preprocessor = self.preprocessors.get(state_machine.preprocess_type) + if not preprocessor: + logger.info(f"No preprocessor found for type: {state_machine.preprocess_type}") + return {'action': 'continue', 'prompt': original_prompt} + + # Execute preprocessing + logger.info(f"Executing {state_machine.preprocess_type} preprocessing for state: {state_machine.name}") + preprocess_response = preprocessor.preprocess(state_machine, **kwargs) + + # Handle output based on mode + result = PreprocessOutputHandler.handle_output( + state_machine.preprocess_output_mode, preprocess_response, original_prompt, **kwargs + ) + + return result + + def register_preprocessor(self, preprocess_type, preprocessor): + """Register a new preprocessor""" + self.preprocessors[preprocess_type] = preprocessor diff --git a/chatbot/services/response_handlers/__init__.py b/chatbot/services/response_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/response_handlers/base_response_handler.py b/chatbot/services/response_handlers/base_response_handler.py new file mode 100644 index 0000000..470fb7d --- /dev/null +++ b/chatbot/services/response_handlers/base_response_handler.py @@ -0,0 +1,640 @@ +from abc import ABC, abstractmethod +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_response_api +from chatbot.models import ChatSession, ChatStatus, LLMProvider, CompanyBotTypeChoices +from chatbot.models.company_models import CompanyStateMachine +from chatbot.models.enums import OperationTypeChoices, PreProcessOutputMode +from chatbot.services.postprocessing.postprocessing_service import PostprocessingService +from chatbot.services.preprocessing.preprocessing_service import PreprocessingService +import logging + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + + +class BaseResponseHandler(ABC): + """Base class for handling LLM responses with common functionality""" + + def __init__(self): + self.default_error_message = 'I am sorry, I could not understood completely. Could you rephrase this please?' + self.preprocessing_service = PreprocessingService() + self.postprocessing_service = PostprocessingService() + self.min_word_count = 3 + self.max_retry_attempts = 2 + + def _is_response_too_short(self, response): + """ + Check if response is too short (less than 3 words). + Returns True if response needs to be regenerated. + """ + try: + if not response or response == '': + return False + + if isinstance(response, dict): + response_text = response.get('response', '') + if not response_text: + return False + else: + response_text = str(response) + + response_text = response_text.strip() + if not response_text: + return False + + word_count = len(response_text.split()) + + logger.info(f"Response word count: {word_count}, text: '{response_text[:100]}...'") + + if word_count < self.min_word_count: + logger.info(f"Response too short: {word_count} words (minimum: {self.min_word_count})") + return True + + return False + + except Exception as e: + logger.error(f"Error checking response length: {e}") + return False + + def is_non_llm_state(self, state_machine): + return ( + state_machine + and hasattr(state_machine, 'operation_type') + and state_machine.operation_type == OperationTypeChoices.NON_LLM + ) + + def build_non_llm_function_call(self, state_machine): + return { + "toolUseId": "non_llm_auto", + "name": "get_state_information", + "input": { + "next_state_name": state_machine.name, + "reason": "NON_LLM auto transition" + } + } + + def handle_response(self, **kwargs): + """Main response handling method""" + session_id = kwargs['session_id'] + chat_session = ChatSession.objects.get(session=session_id) + chunks = [] + is_function_call = False + early_return = self.check_early_return(chat_session, **kwargs) + if early_return is not None: + if isinstance(early_return, str): + return early_return + elif isinstance(early_return, dict): + if early_return.get('skip_llm', False): + kwargs['skip_llm'] = True + else: + is_function_call = self.is_function_call(response=early_return) + else: + return early_return + + company_bot = kwargs.get('company_bot') + try: + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + except Exception as e: + logger.error(f"Error getting state machine: {e}") + state_machine = None + + if self.is_non_llm_state(state_machine): + from chatbot.models import CompanyChat + + user_messages_for_state = CompanyChat.objects.filter( + session=session_id, + stage=state_machine.name + ).exclude(message=state_machine.bot_question).exists() + + kwargs['skip_llm'] = True + kwargs['skip_reason'] = 'non_llm_operation_type' + + if not user_messages_for_state: + kwargs['send_bot_question'] = True + kwargs['bot_question_from_db'] = state_machine.bot_question or None + logger.info(f"NON_LLM state {state_machine.name}: Asking question") + + else: + kwargs['send_bot_question'] = False + kwargs['force_function_call'] = True + logger.info(f"NON_LLM state {state_machine.name}: Advancing to next state") + + original_prompt = kwargs.get('system_prompt', []) + + preprocessing_result = {'action': 'continue', 'prompt': original_prompt} + if state_machine and state_machine.preprocess_output_mode not in [ + PreProcessOutputMode.NONE, PreProcessOutputMode.SKIP, PreProcessOutputMode.MODIFY_QUESTION + ]: + preprocessing_result = self.preprocessing_service.execute_preprocessing( + state_machine, original_prompt, **kwargs + ) + if preprocessing_result['action'] == 'skip': + kwargs['skip_llm'] = True + kwargs['skip_reason'] = 'preprocessing' + elif preprocessing_result['action'] == 'modify_question': + kwargs['modified_bot_question'] = preprocessing_result.get('modified_bot_question') + kwargs['system_prompt'] = preprocessing_result.get('prompt', original_prompt) + logger.info(f"Preprocessing modified bot_question: {kwargs['modified_bot_question']}") + elif preprocessing_result['action'] == 'continue': + kwargs['system_prompt'] = preprocessing_result.get('prompt', original_prompt) + + response = None + streaming_completed = False + if not is_function_call and not kwargs.get('skip_llm', False): + result = self.get_llm_response(**kwargs) + + if isinstance(result, tuple): + response, extra_content, finish_reason = result + + # Store extra_content if present for later use + if extra_content: + kwargs['llm_extra_content'] = extra_content + else: + response = result + finish_reason = None + + use_streaming = self.should_use_streaming(company_bot) + + streaming_completed = finish_reason == "stop" and use_streaming + + # Only treat None as error + if response is None: + if company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + response = { + "toolUseId": "tooluse_fallback", "name": "get_state_information", + "input": { + "next_state_name": "SAMPLE", + "reason": "LLM returned no response" + } + } + else: + response = self.default_error_message + + if is_function_call and response is None: + response = early_return + if kwargs.get('force_function_call') and state_machine: + is_function_call = True + response = self.build_non_llm_function_call(state_machine) + + if not is_function_call: + is_function_call = self.is_function_call(response=response) if state_machine else False + if is_function_call and state_machine and response: + postprocessing_result = self.postprocessing_service.execute_postprocessing( + state_machine, response, **kwargs + ) + + if postprocessing_result.get('skip_next_stage', False): + kwargs['skip_next_stage'] = True + kwargs['target_stage'] = state_machine.skip_to_step + logger.info("Postprocessing will skip next stage") + + next_stage_number = kwargs['target_stage'] + try: + next_state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=next_stage_number + ) + + next_stage_preprocessing_result = self.preprocessing_service.execute_preprocessing( + next_state_machine, kwargs.get('system_prompt', []), **kwargs + ) + + if next_stage_preprocessing_result['action'] == 'skip': + kwargs['skip_next_stage_preprocessing'] = True + elif next_stage_preprocessing_result['action'] == 'modify_question': + kwargs['modified_bot_question'] = next_stage_preprocessing_result.get('modified_bot_question') + kwargs['system_prompt'] = next_stage_preprocessing_result.get('prompt', original_prompt) + logger.info(f"Preprocessing modified bot_question: {kwargs['modified_bot_question']}") + + except CompanyStateMachine.DoesNotExist: + logger.error(f"Next state machine {next_stage_number} not found for preprocessing") + else: + next_stage_number = chat_session.current_step + 1 + try: + next_state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=next_stage_number + ) + + next_stage_preprocessing_result = self.preprocessing_service.execute_preprocessing( + next_state_machine, kwargs.get('system_prompt', []), **kwargs + ) + + if next_stage_preprocessing_result['action'] == 'skip': + kwargs['skip_next_stage_preprocessing'] = True + elif next_stage_preprocessing_result['action'] == 'modify_question': + kwargs['modified_bot_question'] = next_stage_preprocessing_result.get('modified_bot_question') + kwargs['system_prompt'] = next_stage_preprocessing_result.get('prompt', original_prompt) + logger.info(f"Preprocessing modified bot_question: {kwargs['modified_bot_question']}") + + except CompanyStateMachine.DoesNotExist: + logger.info(f"Next state machine {next_stage_number} not found, likely at end of flow") + + return self.process_response( + response, chat_session, chunks, streaming_completed=streaming_completed, **kwargs + ) + + def analyze_response_for_postprocessing(self, response): + """Analyze if response needs postprocessing - can be overridden by subclasses""" + return self.is_function_call(response) + + def should_use_streaming(self, company_bot): + """ + Determine if streaming should be used for this bot. + """ + try: + if hasattr(company_bot, 'stream'): + return bool(company_bot.stream) + + return False + + except Exception as e: + logger.error(f"Error determining streaming mode: {e}") + return False + + def get_llm_response(self, **kwargs): + """Get response from LLM provider""" + company_bot = kwargs['company_bot'] + system_prompt = kwargs['system_prompt'] + response = None + message_to_send = self.get_messages_for_llm(**kwargs) + print("message_to_send: ", message_to_send) + session_id = kwargs['session_id'] + profile_id = kwargs.get('profile_id') + channel_name = kwargs['channel_name'] + chat_session = ChatSession.objects.get(session=session_id) + state_machine = None + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + except Exception as e: + logger.error(f"Error getting state machine for tools: {e}") + + tools = None + has_state_machine_tool_context = ( + state_machine + and hasattr(state_machine, 'tool_context') + and state_machine.tool_context + and state_machine.tool_context.strip() + ) + + has_company_bot_tool_context = ( + company_bot + and hasattr(company_bot, 'tool_context') + and company_bot.tool_context + and company_bot.tool_context.strip() + ) + + if has_state_machine_tool_context or ( + company_bot.bot_type == CompanyBotTypeChoices.SIMPLE and has_company_bot_tool_context + ): + tool_context = ( + state_machine.tool_context.strip() + if has_state_machine_tool_context + else company_bot.tool_context.strip() + ) + + try: + import json_repair + tools = json_repair.repair_json(tool_context, return_objects=True) + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + tools = None + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=message_to_send, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + except Exception as e: + logger.error(f"Bedrock Error: %s", e) + response = None + + elif company_bot.provider == LLMProvider.OPENAI: + use_streaming = self.should_use_streaming(company_bot) + + logger.info(f"Using OpenAI {'streaming' if use_streaming else 'non-streaming'} for session {session_id}") + + result = self._handle_openai_response( + system_prompt=system_prompt, + messages=message_to_send, + company_bot=company_bot, + channel_name=channel_name, + session_id=session_id, + profile_id=profile_id, + stream=use_streaming + ) + + if result is None or (isinstance(result, tuple) and result[0] is None): + logger.error("OpenAI response returned None - error occurred") + response = None + elif isinstance(result, tuple): + response, extra_content, finish_reason = result + + if extra_content: + print("Setting extra_content to ", extra_content) + kwargs['llm_extra_content'] = extra_content + + return response, extra_content, finish_reason + else: + response = result + return response, None, None + + def _handle_openai_response(self, system_prompt, messages, company_bot, + channel_name, session_id, profile_id, stream=False): + """ + Handle OpenAI response using handle_openai_response_api. + Works for both streaming and non-streaming modes. + Supports both file_search and function calling tools. + """ + final_extra_content = None + function_call_result = None + + try: + logger.info(f"Processing free-flow for session {session_id}, channel {channel_name}") + + tools = None + tool_choice = None + try: + import json_repair + tool_context = json_repair.repair_json(company_bot.tool_context, return_objects=True) + if tool_context: + # Handle both formats: flat array or dict with "tool" key + if isinstance(tool_context, list): + tools = tool_context + tool_choice = "auto" + elif isinstance(tool_context, dict): + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}", exc_info=True) + print(f"❌ Error parsing tool_context: {e}") + + accumulated_response = "" + finish_reason = None + + for chunk_data in handle_openai_response_api( + messages=messages, + system_prompt=system_prompt, + max_token=company_bot.max_token if company_bot.max_token else 2048, + temperature=company_bot.bot_temperature if company_bot.bot_temperature is not None else 0.0, + company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice=tool_choice, + tools=tools, + stream=stream + ): + content = chunk_data.get('content', '') + finish_reason = chunk_data.get('finish_reason') + error = chunk_data.get('error') + extra_content = chunk_data.get('extra_content') + function_call = chunk_data.get('function_call') + + if error: + logger.error(f'OpenAI error: {error}') + if stream: + self._send_error_chunk(channel_name, "Error processing your request") + return None + + # Handle function call response + if function_call: + function_call_result = function_call + logger.info(f"Function call received: {function_call['name']}") + logger.info(f"Function arguments: {function_call.get('arguments', {})}") + # Don't send function_call via WebSocket here - it will be handled by common_handler + # This prevents duplicate WebSocket messages + + if content: + accumulated_response += content + + if extra_content: + print("got extra_content: ", extra_content) + final_extra_content = extra_content + + print(f"Finish reason: {finish_reason}") + + if stream: + if content: + self._send_chunk(channel_name, content, None, None) + + if finish_reason == "stop": + self._send_chunk( + channel_name, + "", + "stop", + final_extra_content + ) + # If function call was made, return it for processing + if function_call_result: + logger.info(f"Returning function call result: {function_call_result}") + # Return function call as response dict with finish_reason + response_dict = { + 'function_call': function_call_result, + 'finish_reason': 'function_call', + 'extra_content': final_extra_content # Include sources if available + } + return response_dict, final_extra_content, 'function_call' + + if accumulated_response: + save_in_company_db( + session_id=session_id, + profile_id=profile_id, + initiated_by='AI', + message=accumulated_response, + chunks=None, + status=ChatStatus.IN_PROGRESS, + stage=None + ) + + logger.info(f'Completed OpenAI response, length: {len(accumulated_response)} chars') + + return accumulated_response, final_extra_content, finish_reason + + except Exception as e: + logger.error(f'Error in OpenAI response handling: {e}', exc_info=True) + if stream: + self._send_error_chunk(channel_name, "An error occurred processing your message") + return None, None, None + + def _send_chunk(self, channel_name, content, finish_reason, extra_content=None): + """Send a chunk via channel layer to the WebSocket.""" + try: + message_data = { + "type": "chat.message", + "text": { + "msg": content, + "source": "bot", + "type": "chunk", + "finish_reason": finish_reason + }, + } + + if extra_content: + message_data["text"]["extra_content"] = extra_content + + async_to_sync(channel_layer.send)(channel_name, message_data) + + except Exception as e: + logger.error(f"Failed to send chunk to channel {channel_name}: {e}", exc_info=True) + + def _send_error_chunk(self, channel_name, error_msg): + """Send error message via channel layer to the WebSocket.""" + try: + async_to_sync(channel_layer.send)( + channel_name, + { + "type": "chat.message", + "text": { + "msg": error_msg, + "source": "bot", + "type": "error", + "finish_reason": "error" + }, + }, + ) + except Exception as e: + logger.error(f"Failed to send error to channel {channel_name}: {e}") + + def get_default_tools_config(self): + """Get default tools configuration - fallback for when no tool_context is available""" + return [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + + def get_tools_config(self): + """Deprecated - use get_default_tools_config() or PromptBuilder.get_tools_from_state_machine()""" + logger.info("get_tools_config() is deprecated, use get_default_tools_config() instead") + return self.get_default_tools_config() + + def is_function_call(self, response): + """Check if response is a function call""" + if isinstance(response, dict): + if 'toolUseId' in response and 'name' in response: + return response.get('name') == 'get_state_information' + + elif 'name' in response and 'parameters' in response: + return response.get('name') == 'get_state_information' + + elif 'function_call' in response: + function_call = response.get('function_call', {}) + return function_call.get('name') == 'get_state_information' + + elif 'tool_calls' in response: + tool_calls = response.get('tool_calls', []) + for tool_call in tool_calls: + if 'function' in tool_call: + function = tool_call.get('function', {}) + if function.get('name') == 'get_state_information': + return True + return False + + elif 'output' in response and 'message' in response.get('output', {}): + content = response['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + tool_use = item.get('toolUse', {}) + if tool_use.get('name') == 'get_state_information': + return True + return False + + elif 'parameters' in response or 'input' in response: + nested_data = response.get('parameters') or response.get('input') + if isinstance(nested_data, dict): + if 'next_state_name' in nested_data: + return True + elif 'response' in nested_data: + return False + else: + return 'get_state_information' in str(nested_data) + return 'get_state_information' in str(nested_data) + + elif any(key in response for key in ['toolUseId', 'tool_calls', 'function_call']): + return 'get_state_information' in str(response) + + return False + + elif isinstance(response, str): + return 'get_state_information' in response + + return False + + def save_message(self, session_id, profile_id, message, chunks, + status, translated_message, stage=None, other_params=None): + """Save message to database""" + save_in_company_db( + session_id=session_id, + profile_id=profile_id, + initiated_by='AI', + message=message, + chunks=chunks, + status=status, + translated_message=translated_message, + stage=stage, + other_params=other_params + ) + + def translate_message(self, message, channel_name, step_number, language, company_bot, extra_content=None): + """Translate and send message""" + return translate_and_send_message( + accumulated_message=message, + current_channel_name=channel_name, + current_step_number=step_number, + finish_reason="stop", + route=language, + company_bot=company_bot, + extra_content=extra_content + ) + + def get_chat_status(self, state_machine, company_bot): + """Determine chat status based on state""" + last_state = CompanyStateMachine.objects.filter(company_bot=company_bot).order_by('step').last() + max_step = last_state.step if last_state else None + + if state_machine.step == max_step: + return ChatStatus.COMPLETED + else: + return ChatStatus.IN_PROGRESS + + @abstractmethod + def check_early_return(self, chat_session, **kwargs): + """Check if we should return early (bot-specific logic)""" + pass + + @abstractmethod + def get_messages_for_llm(self, **kwargs): + """Get appropriate messages for LLM""" + pass + + @abstractmethod + def process_response(self, response, chat_session, chunks, **kwargs): + """Process the LLM response (bot-specific logic)""" + pass diff --git a/chatbot/services/response_handlers/base_response_handler_new.py b/chatbot/services/response_handlers/base_response_handler_new.py new file mode 100644 index 0000000..e35c5e9 --- /dev/null +++ b/chatbot/services/response_handlers/base_response_handler_new.py @@ -0,0 +1,403 @@ +from abc import ABC, abstractmethod +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, LLMProvider, CompanyBotTypeChoices +from chatbot.models.company_models import CompanyStateMachine +from chatbot.models.enums import OperationTypeChoices +from chatbot.services.postprocessing.postprocessing_service import PostprocessingService +from chatbot.services.preprocessing.preprocessing_service import PreprocessingService +import logging + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + +class BaseResponseHandlerNew(ABC): + """Base class for handling LLM responses with common functionality""" + + def __init__(self): + self.default_error_message = 'I am sorry, I could not understood completely. Could you rephrase this please?' + self.preprocessing_service = PreprocessingService() + self.postprocessing_service = PostprocessingService() + self.min_word_count = 3 + self.max_retry_attempts = 2 + + def _is_response_too_short(self, response): + """ + Check if response is too short (less than 3 words). + Returns True if response needs to be regenerated. + """ + try: + if not response or response == '': + return False + + if isinstance(response, dict): + response_text = response.get('response', '') + if not response_text: + return False + else: + response_text = str(response) + + response_text = response_text.strip() + if not response_text: + return False + + word_count = len(response_text.split()) + + logger.info(f"Response word count: {word_count}, text: '{response_text[:100]}...'") + + if word_count < self.min_word_count: + logger.info(f"Response too short: {word_count} words (minimum: {self.min_word_count})") + return True + + return False + + except Exception as e: + logger.error(f"Error checking response length: {e}") + return False + + def is_non_llm_state(self, state_machine): + return ( + state_machine + and hasattr(state_machine, 'operation_type') + and state_machine.operation_type == OperationTypeChoices.NON_LLM + ) + + def build_non_llm_function_call(self, state_machine): + return { + "toolUseId": "non_llm_auto", + "name": "get_state_information", + "input": { + "next_state_name": state_machine.name, + "reason": "NON_LLM auto transition" + } + } + + def handle_response(self, **kwargs): + """Main response handling method""" + # Extract common parameters + session_id = kwargs['session_id'] + chat_session = ChatSession.objects.get(session=session_id) + chunks = [] + is_function_call = False + early_return = self.check_early_return(chat_session, **kwargs) + if early_return is not None: + if isinstance(early_return, str): + return early_return + elif isinstance(early_return, dict): + if early_return.get('skip_llm', False): + kwargs['skip_llm'] = True + else: + is_function_call = self.is_function_call(response=early_return) + else: + return early_return + + company_bot = kwargs.get('company_bot') + try: + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + except Exception as e: + logger.error(f"Error getting state machine: {e}") + state_machine = None + + # Prepare original prompt + original_prompt = kwargs.get('system_prompt', []) + + # Execute preprocessing if state machine exists + preprocessing_result = {'action': 'continue', 'prompt': original_prompt} + if state_machine: + preprocessing_result = self.preprocessing_service.execute_preprocessing( + state_machine, original_prompt, **kwargs + ) + + # Handle preprocessing results + if preprocessing_result['action'] == 'skip': + # Skip the current stage - move to next stage + kwargs['skip_llm'] = True + kwargs['skip_reason'] = 'preprocessing' + elif preprocessing_result['action'] == 'continue': + # Update prompt if it was enriched + kwargs['system_prompt'] = preprocessing_result.get('prompt', original_prompt) + + response = None + # Get LLM response + if not kwargs.get('skip_llm', False): + response = self.get_llm_response(**kwargs) + print("before response: ", response) + if response is None: + if company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + response = { + "toolUseId": "tooluse_fallback", "name": "get_state_information", + "input": { + "next_state_name": "SAMPLE", + "reason": "LLM returned no response" + } + } + else: + response = self.default_error_message + + if is_function_call and response is None: + response = early_return + if kwargs.get('force_function_call') and state_machine: + is_function_call = True + response = self.build_non_llm_function_call(state_machine) + + if not is_function_call: + is_function_call = self.is_function_call(response=response) if state_machine else False + if is_function_call and state_machine and response: + postprocessing_result = self.postprocessing_service.execute_postprocessing( + state_machine, response, **kwargs + ) + + # Handle postprocessing results + if postprocessing_result.get('skip_next_stage', False): + kwargs['skip_next_stage'] = True + kwargs['target_stage'] = state_machine.skip_to_step + logger.info("Postprocessing will skip next stage") + + # Process the response + return self.process_response( + response, chat_session, chunks, **kwargs + ) + + def analyze_response_for_postprocessing(self, response): + """Analyze if response needs postprocessing - can be overridden by subclasses""" + return self.is_function_call(response) + + def get_llm_response(self, **kwargs): + """Get response from LLM provider""" + company_bot = kwargs['company_bot'] + system_prompt = kwargs['system_prompt'] + response = None + message_to_send = self.get_messages_for_llm(**kwargs) + + session_id = kwargs['session_id'] + profile_id = kwargs.get('profile_id') + channel_name = kwargs['channel_name'] + chat_session = ChatSession.objects.get(session=session_id) + state_machine = None + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + except Exception as e: + logger.error(f"Error getting state machine for tools: {e}") + + tools = None + has_state_machine_tool_context = ( + state_machine + and hasattr(state_machine, 'tool_context') + and state_machine.tool_context + and state_machine.tool_context.strip() + ) + + has_company_bot_tool_context = ( + company_bot + and hasattr(company_bot, 'tool_context') + and company_bot.tool_context + and company_bot.tool_context.strip() + ) + + if has_state_machine_tool_context or ( + company_bot.bot_type == CompanyBotTypeChoices.SIMPLE and has_company_bot_tool_context + ): + tool_context = ( + state_machine.tool_context.strip() + if has_state_machine_tool_context + else company_bot.tool_context.strip() + ) + + try: + import json_repair + tools = json_repair.repair_json(tool_context, return_objects=True) + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + tools = None + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=message_to_send, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tools + ) + except Exception as e: + logger.error(f"Bedrock Error: %s", e) + response = None + + elif company_bot.provider == LLMProvider.OPENAI: + openai_tools = tools if tools else self.get_default_tools_config() + response = handle_openai_model( + system_prompt=system_prompt, + messages=message_to_send, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + tools=openai_tools, + tool_choice='auto', + is_json_response=False + ) + + return response + + def get_default_tools_config(self): + """Get default tools configuration - fallback for when no tool_context is available""" + return [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + + def get_tools_config(self): + """Deprecated - use get_default_tools_config() or PromptBuilder.get_tools_from_state_machine()""" + logger.info("get_tools_config() is deprecated, use get_default_tools_config() instead") + return self.get_default_tools_config() + + def is_function_call(self, response): + """Check if response is a function call""" + if isinstance(response, dict): + # Check for various function call formats + + # Format 1: Direct tool use format (OLD) + # {'toolUseId': '...', 'name': 'get_state_information', 'input': {...}} + if 'toolUseId' in response and 'name' in response: + return response.get('name') == 'get_state_information' + + # Format 2: Simple function call format (NEW) + # {'name': 'get_state_information', 'parameters': {...}} + elif 'name' in response and 'parameters' in response: + return response.get('name') == 'get_state_information' + + # Format 3: OpenAI function_call format + # {'function_call': {'name': 'get_state_information', 'arguments': {...}}} + elif 'function_call' in response: + function_call = response.get('function_call', {}) + return function_call.get('name') == 'get_state_information' + + # Format 4: OpenAI tool_calls format + # {'tool_calls': [{'function': {'name': 'get_state_information', ...}}]} + elif 'tool_calls' in response: + tool_calls = response.get('tool_calls', []) + for tool_call in tool_calls: + if 'function' in tool_call: + function = tool_call.get('function', {}) + if function.get('name') == 'get_state_information': + return True + return False + + # Format 5: Bedrock response format + # {'output': {'message': {'content': [{'toolUse': {'name': 'get_state_information', ...}}]}}} + elif 'output' in response and 'message' in response.get('output', {}): + content = response['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + tool_use = item.get('toolUse', {}) + if tool_use.get('name') == 'get_state_information': + return True + return False + + # Format 6: Just 'parameters' or 'input' without 'name' - check carefully + elif 'parameters' in response or 'input' in response: + # If it only has parameters/input but no clear function call indicators, + # check if the nested data contains function call info + nested_data = response.get('parameters') or response.get('input') + if isinstance(nested_data, dict): + # Function calls have 'next_state_name' - this is the key differentiator + if 'next_state_name' in nested_data: + return True + # Regular responses have 'response' key - this indicates it's not a function call + elif 'response' in nested_data: + return False + # If neither, fall back to string search + else: + return 'get_state_information' in str(nested_data) + # Check if 'get_state_information' appears in the nested data + return 'get_state_information' in str(nested_data) + + # Format 7: Check if 'get_state_information' appears anywhere in the dict values + # But be more restrictive - only if it appears as a function name, not in text + elif any(key in response for key in ['toolUseId', 'tool_calls', 'function_call']): + return 'get_state_information' in str(response) + + # If none of the above, it's likely a regular dict response (like {"response": "...", "reason": "..."}) + return False + + elif isinstance(response, str): + return 'get_state_information' in response + + return False + + def save_message(self, session_id, profile_id, message, chunks, + status, translated_message, stage=None, other_params=None): + """Save message to database""" + save_in_company_db( + session_id=session_id, + profile_id=profile_id, + initiated_by='AI', + message=message, + chunks=chunks, + status=status, + translated_message=translated_message, + stage=stage, + other_params=other_params + ) + + def translate_message(self, message, channel_name, step_number, language, company_bot, extra_content=None): + """Translate and send message""" + return translate_and_send_message( + accumulated_message=message, + current_channel_name=channel_name, + current_step_number=step_number, + finish_reason="stop", + route=language, + company_bot=company_bot, + extra_content=extra_content + ) + + def get_chat_status(self, state_machine, company_bot): + """Determine chat status based on state""" + last_state = CompanyStateMachine.objects.filter(company_bot=company_bot).order_by('step').last() + max_step = last_state.step if last_state else None + + if state_machine.step == max_step: + return ChatStatus.COMPLETED + else: + return ChatStatus.IN_PROGRESS + # return ChatStatus.COMPLETED if state_machine.name == "APPRECIATION" else ChatStatus.IN_PROGRESS + + # Abstract methods to be implemented by specific bot handlers + @abstractmethod + def check_early_return(self, chat_session, **kwargs): + """Check if we should return early (bot-specific logic)""" + pass + + @abstractmethod + def get_messages_for_llm(self, **kwargs): + """Get appropriate messages for LLM""" + pass + + @abstractmethod + def process_response(self, response, chat_session, chunks, **kwargs): + """Process the LLM response (bot-specific logic)""" + pass diff --git a/chatbot/services/response_handlers/common_handler.py b/chatbot/services/response_handlers/common_handler.py new file mode 100644 index 0000000..76aa280 --- /dev/null +++ b/chatbot/services/response_handlers/common_handler.py @@ -0,0 +1,868 @@ +from chatbot.models import ChatStatus, CompanyChat, CompanyBotTypeChoices, LLMProvider, BotVernacular +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.response_handlers.base_response_handler import BaseResponseHandler +from chatbot.utils.shiksha_chaupal.date_utils import handle_date_prompt +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +import logging +import json +from json_repair import repair_json +from chatbot.utils.media_preview.media_creation import create_and_upload_file + +logger = logging.getLogger('django') + + +class CommonResponseHandler(BaseResponseHandler): + """Common Response handler for bot""" + + def check_early_return(self, chat_session, **kwargs): + """Check for EVENT state early return""" + company_bot = kwargs['company_bot'] + + try: + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + if state_machine and state_machine.name == 'EVENT_DATE': + print("Here inside") + return self._handle_event_state(chat_session=chat_session, state_machine=state_machine, **kwargs) + except Exception as e: + print("Error: ", e) + logger.error(f"Error in check_early_return: {e}") + + return None + + def _handle_event_state(self, chat_session, state_machine, **kwargs): + """Handle EVENT state with date prompt""" + intro_mssg = kwargs.get('intro_mssg') + profile = kwargs.get('profile') + other_info = kwargs.get('other_info') + channel_name = kwargs['channel_name'] + language = kwargs['language'] + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + profile_id = kwargs['profile_id'] + print("Before date fun") + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + bot_question = handle_date_prompt( + intro_mssg=intro_mssg, + profile=profile, + company_chats=company_chats, + other_info=other_info + ) + print("DATE RES: ", bot_question) + if bot_question is None: + bot_question = self.default_error_message + + if bot_question == '': + return { + "toolUseId": "tooluse_auto_advance", + "name": "get_state_information", + "input": { + "next_state_name": 'AUTO', + "reason": "Date parsed successfully" + } + } + else: + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + stage = state_machine.name if state_machine else None + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=None, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=stage + ) + + return bot_question + + def get_messages_for_llm(self, **kwargs): + """Use temp_messages if available, otherwise original messages""" + temp_messages = kwargs.get('temp_messages') + messages = kwargs.get('messages') + return temp_messages if temp_messages else messages + + def _send_db_question(self, bot_question, chat_session, chunks, **kwargs): + """Send bot question from database for NON_LLM operations""" + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + + modified_bot_question = kwargs.get('modified_bot_question') + if modified_bot_question: + bot_question = modified_bot_question + logger.info(f"Using modified bot_question from preprocessing: {bot_question[:100]}") + + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + except CompanyStateMachine.DoesNotExist: + logger.error(f"State machine not found for step {chat_session.current_step}") + return self.default_error_message + + chat_status = self.get_chat_status( + state_machine=state_machine, company_bot=company_bot + ) + + # Translate and send to WebSocket + translated_message = self.translate_message( + message=bot_question, + channel_name=channel_name, + step_number=chat_session.current_step, + language=language, + company_bot=company_bot + ) + + # Save to database with metadata indicating this is a NON_LLM question from DB + self.save_message( + session_id=session_id, + profile_id=profile_id, + message=bot_question, + chunks=chunks, + status=chat_status, + translated_message=translated_message, + stage=state_machine.name, + other_params={ + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + } + ) + + logger.info(f"Sent NON_LLM bot question from DB for state: {state_machine.name}") + return bot_question + + + def is_function_call(self, response): + """Override to handle empty responses as function calls""" + if super().is_function_call(response): + return True + + # Check for OpenAI Responses API function call format + if isinstance(response, dict): + if response.get('finish_reason') == 'function_call' and 'function_call' in response: + print("DEBUG: Detected OpenAI Responses API function call format") + logger.info("Detected OpenAI Responses API function call format") + return True + + if response.get('should_function_call', False): + print("DEBUG: should_function_call is True, treating as function call") + logger.info("should_function_call flag is True, treating as function call") + return True + + try: + extracted_response, _, meta = self._extract_response_and_reason(response) + if meta: + flag = meta.get("should_function_call") + if isinstance(flag, str): + flag = flag.strip().lower() in ("true", "yes", "1") + if flag is True: + print("DEBUG: should_function_call detected via meta in is_function_call") + logger.info("should_function_call detected via meta in is_function_call") + return True + if extracted_response == '': + print( + "DEBUG: Empty response detected in is_function_call, treating as function call for postprocessing") + return True + except Exception as e: + print(f"DEBUG: Error in is_function_call extraction: {e}") + logger.error(f"Error in is_function_call extraction: {e}") + + return False + + def _analyze_response(self, response): + """ + Analyze response to determine if it's a function call and extract content. + """ + is_actual_function_call = self.is_function_call(response=response) + + if is_actual_function_call: + return True, None, None + + extracted_response, reason_text, meta = self._extract_response_and_reason(response) + + if meta: + flag = meta.get("should_function_call") + if isinstance(flag, str): + flag = flag.strip().lower() in ("true", "yes", "1") + if flag is True: + print("DEBUG: should_function_call detected via meta in _analyze_response") + logger.info("should_function_call detected via meta in _analyze_response") + return True, None, None + + if extracted_response == '': + print("DEBUG: Empty response detected after extraction, treating as function call for state transition") + return True, extracted_response, reason_text + + return False, extracted_response, reason_text + + def analyze_response_for_postprocessing(self, response): + """Override to handle empty responses as function calls for postprocessing""" + is_function_call, _, _ = self._analyze_response(response) + return is_function_call + + def process_response(self, response, chat_session, chunks, streaming_completed=False, **kwargs): + """Process common response with retry logic for short responses""" + print(f"DEBUG: Starting process_response with response type: {type(response)}") + print(f"DEBUG: Response preview: {str(response)[:200]}...") + print(f"DEBUG: kwargs keys: {list(kwargs.keys())}") + print(f"DEBUG: streaming_completed in kwargs: {'streaming_completed' in kwargs}") + print(f"DEBUG: streaming_completed value: {kwargs.get('streaming_completed', 'NOT SET')}") + + retry_attempt = kwargs.get('retry_attempt', 0) + print(f"DEBUG: Current retry attempt: {retry_attempt}") + + skip_llm_call = kwargs.get('skip_llm', False) + send_bot_question = kwargs.get('send_bot_question', False) + print(f"DEBUG: skip_llm_call: {skip_llm_call}") + + # Handle NON_LLM operation type - send bot_question directly from database + if send_bot_question and skip_llm_call: + bot_question = kwargs.get('bot_question_from_db') + if bot_question: + return self._send_db_question( + bot_question=bot_question, + chat_session=chat_session, + chunks=chunks, + **kwargs + ) + + current_step = chat_session.current_step + + if skip_llm_call: + is_function_call = True + expected_output_response = None + reason_text = None + print("DEBUG: Skipping LLM call, treating as function call") + else: + is_function_call, expected_output_response, reason_text = self._analyze_response(response) + print(f"DEBUG: Analysis result - is_function_call: {is_function_call}") + print(f"DEBUG: expected_output_response: '{expected_output_response}'") + print(f"DEBUG: reason_text: '{reason_text}'") + + if not is_function_call and retry_attempt < self.max_retry_attempts: + response_to_check = expected_output_response if expected_output_response is not None else response + + if self._is_response_too_short(response_to_check): + logger.info( + f"Response too short, retrying LLM call (attempt {retry_attempt + 1}/{self.max_retry_attempts})") + print( + f"DEBUG: Response too short, retrying LLM call (attempt {retry_attempt + 1}/{self.max_retry_attempts})") + + kwargs['retry_attempt'] = retry_attempt + 1 + + try: + result = self.get_llm_response(**kwargs) + + if isinstance(result, tuple): + new_response, extra_content, finish_reason = result + else: + new_response = result + finish_reason = None + + if new_response: + logger.info("Successfully got new response from LLM on retry") + print("DEBUG: Successfully got new response from LLM on retry") + + return self.process_response( + new_response, chat_session, chunks, + streaming_completed=streaming_completed, + **kwargs + ) + else: + logger.info("LLM returned None on retry, will use error message") + print("DEBUG: LLM returned None on retry, will use error message") + kwargs['use_error_message'] = True + + except Exception as e: + logger.error(f"Error during LLM retry: {e}") + print(f"DEBUG: Error during LLM retry: {e}") + kwargs['use_error_message'] = True + + if not is_function_call and retry_attempt >= self.max_retry_attempts: + response_to_check = expected_output_response if expected_output_response is not None else response + if self._is_response_too_short(response_to_check): + logger.info("Exhausted all retries, response still too short, will use error message") + print("DEBUG: Exhausted all retries, response still too short, will use error message") + kwargs['use_error_message'] = True + + company_bot = kwargs['company_bot'] + language = kwargs['language'] + + forward_kwargs = kwargs.copy() + + forward_kwargs['messages'] = self.get_messages_for_llm(**kwargs) + forward_kwargs['skip_next_stage'] = kwargs.get('skip_next_stage', False) + forward_kwargs['target_stage'] = kwargs.get('target_stage', False) + forward_kwargs['skip_next_stage_preprocessing'] = kwargs.get('skip_next_stage_preprocessing', False) + + if kwargs.get('use_error_message', False) and not is_function_call: + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if ( + bot_vernacular and bot_vernacular.error_message) else "Please try again!" + logger.info(f"Using error message: {error_message}") + print(f"DEBUG: Using error message: {error_message}") + expected_output_response = error_message + response = error_message + + # Handle function calls for STATE_MACHINE bots + if is_function_call and company_bot and company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + print("DEBUG: Processing as STATE_MACHINE function call") + return self._handle_function_call( + response=response, chat_session=chat_session, chunks=chunks, **forward_kwargs + ) + # Handle function calls for FREE_FLOW bots (like download_file) + elif is_function_call and isinstance(response, dict) and 'function_call' in response: + print("DEBUG: Processing as FREE_FLOW function call") + logger.info(f"FREE_FLOW function call detected: {response}") + return self._handle_freeflow_function_call( + response=response, + chat_session=chat_session, + chunks=chunks, + **kwargs + ) + else: + print("DEBUG: Processing as regular response") + final_response = expected_output_response if ( + expected_output_response is not None and expected_output_response != "") else response + return self._handle_regular_response( + response=final_response, chat_session=chat_session, chunks=chunks, current_step=current_step, + streaming_completed=streaming_completed, reason=reason_text, **kwargs + ) + + def _extract_response_and_reason(self, response): + """Extract both response and reason from the response""" + logger.info(f"DEBUG: Extracting response and reason from response type: {type(response)}") + logger.info(f"DEBUG: Response content preview: {str(response)[:200]}...") + + try: + if isinstance(response, str): + logger.info("DEBUG: Response is string") + + stripped = response.strip() + if not (stripped.startswith('{') and stripped.endswith('}')): + if ('"response"' in stripped or '"reason"' in stripped) and ':' in stripped: + logger.info("DEBUG: String looks like JSON without outer braces, adding them") + response = '{' + stripped + '}' + logger.info(f"DEBUG: Fixed JSON string: {response[:200]}...") + + if not (response.strip().startswith('{') and response.strip().endswith('}')): + logger.info("DEBUG: String doesn't look like JSON, treating as plain text response") + return response, None, None + + logger.info("DEBUG: String looks like JSON, attempting parsing") + try: + parsed_response = json.loads(response) + logger.info("DEBUG: Successfully parsed JSON from string") + except json.JSONDecodeError: + try: + repaired_json = repair_json(response) + parsed_response = json.loads(repaired_json) + logger.info("DEBUG: Successfully repaired and parsed JSON") + except Exception as repair_error: + logger.info(f"DEBUG: JSON repair failed: {repair_error}") + return response, None, None + + response = parsed_response + + if isinstance(response, dict): + logger.info("DEBUG: Processing dict response") + logger.info(f"DEBUG: Dict keys: {list(response.keys())}") + + response_copy = response.copy() + extracted_data = None + + if 'toolUseId' in response_copy and 'input' in response_copy: + logger.info("DEBUG: Found direct tool use format (toolUseId + input)") + extracted_data = response_copy.get('input') + + elif 'name' in response_copy and 'parameters' in response_copy: + logger.info("DEBUG: Found simple function call format (name + parameters)") + extracted_data = response_copy.get('parameters') + + elif 'parameters' in response_copy: + logger.info("DEBUG: Found parameters format") + extracted_data = response_copy.get('parameters') + + elif 'input' in response_copy: + logger.info("DEBUG: Found input format") + extracted_data = response_copy.get('input') + + elif 'output' in response_copy and 'message' in response_copy['output']: + logger.info("DEBUG: Found Bedrock nested response format") + content = response_copy['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + extracted_data = item['toolUse'].get('input', {}) + break + + elif 'function_call' in response_copy: + logger.info("DEBUG: Found OpenAI function_call format") + arguments = response_copy['function_call'].get('arguments', {}) + if isinstance(arguments, str): + try: + extracted_data = json.loads(arguments) + except json.JSONDecodeError: + try: + repaired_arguments = repair_json(arguments) + extracted_data = json.loads(repaired_arguments) + except Exception: + extracted_data = None + else: + extracted_data = arguments + + elif 'tool_calls' in response_copy: + logger.info("DEBUG: Found OpenAI tool_calls format") + tool_calls = response_copy['tool_calls'] + if tool_calls and len(tool_calls) > 0: + tool_call = tool_calls[0] + if 'function' in tool_call: + arguments = tool_call['function'].get('arguments', {}) + if isinstance(arguments, str): + try: + extracted_data = json.loads(arguments) + except json.JSONDecodeError: + try: + repaired_arguments = repair_json(arguments) + extracted_data = json.loads(repaired_arguments) + except Exception: + extracted_data = None + else: + extracted_data = arguments + else: + logger.info("DEBUG: Checking if response/reason are directly in dict") + if 'response' in response_copy or 'reason' in response_copy: + extracted_data = response_copy + + logger.info(f"DEBUG: Extracted data type: {type(extracted_data)}") + if extracted_data: + logger.info( + f"DEBUG: Extracted data keys: {list(extracted_data.keys()) if isinstance(extracted_data, dict) else 'Not a dict'}") + + meta = extracted_data.copy() if isinstance(extracted_data, dict) else None + if extracted_data and isinstance(extracted_data, dict): + if len(extracted_data) == 0: + print( + f"DEBUG: Empty extracted_data detected from LLM (input/parameters) - treating as function call") + logger.info( + f"Empty extracted_data detected from LLM (input/parameters) - treating as function call: {response}") + return '', ('LLM returned empty input/parameters - treating as function call to ' + 'proceed to next state'), meta + + response_text = extracted_data.get('response', '') + reason_text = extracted_data.get('reason', '') + logger.info( + f"DEBUG: Final extraction - response: '{response_text[:100]}...', " + f"reason: '{reason_text[:100]}...'") + return response_text, reason_text, meta + + elif extracted_data and isinstance(extracted_data, str): + logger.info("DEBUG: Extracted data is string, trying to parse as JSON") + try: + parsed_data = json.loads(extracted_data) + except json.JSONDecodeError: + try: + repaired_data = repair_json(extracted_data) + parsed_data = json.loads(repaired_data) + logger.info("DEBUG: Successfully repaired string data") + except Exception as e: + logger.info(f"DEBUG: Failed to parse string data: {e}") + return extracted_data, None, meta + + if isinstance(parsed_data, dict): + response_text = parsed_data.get('response', '') + reason_text = parsed_data.get('reason', '') + logger.info( + f"DEBUG: Parsed string data - response: '{response_text[:100]}...', reason: " + f"'{reason_text[:100]}...'") + meta = parsed_data.copy() + return response_text, reason_text, meta + + logger.info("DEBUG: No specific format matched, checking original dict for response/reason") + response_text = response_copy.get('response', '') + reason_text = response_copy.get('reason', '') + if response_text or reason_text: + logger.info( + f"DEBUG: Found in original dict - response: '{response_text[:100]}...', reason: " + f"'{reason_text[:100]}...'" + ) + return response_text, reason_text, meta + + except Exception as e: + logger.info(f"DEBUG: Error extracting response and reason: {e}") + logger.error(f"Error extracting response and reason: {e}") + + logger.info("DEBUG: Fallback - returning original response as string") + final_response = str(response) if not isinstance(response, str) else response + return final_response, None, None + + def _extract_expected_output(self, response): + """Extract expected_output from function call response if it exists and is not empty""" + print(f"DEBUG: Extracting expected_output from response type: {type(response)}") + print(f"DEBUG: Response content: {response}") + + def _extract_and_return(expected_output, format_name): + """Helper to extract and return expected_output if not empty""" + print(f"DEBUG: Expected output from {format_name}: '{expected_output}'") + return expected_output if expected_output else None + + def _parse_json_string(json_str): + """Helper to safely parse JSON string""" + try: + import json + return json.loads(json_str) + except json.JSONDecodeError: + return None + + try: + if isinstance(response, dict): + if 'name' in response and 'parameters' in response: + print("DEBUG: Found simple function call format (NEW)") + expected_output = response['parameters'].get('expected_output', '') + return _extract_and_return(expected_output, "simple function call") + + elif 'toolUseId' in response and 'input' in response: + print("DEBUG: Found direct tool use format (OLD)") + expected_output = response['input'].get('expected_output', '') + return _extract_and_return(expected_output, "direct tool use") + + elif 'output' in response and 'message' in response['output']: + print("DEBUG: Found Bedrock response format") + content = response['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + expected_output = item['toolUse'].get('input', {}).get('expected_output', '') + return _extract_and_return(expected_output, "Bedrock format") + + elif 'function_call' in response or 'tool_calls' in response: + print("DEBUG: Found OpenAI-style function call format") + + if 'tool_calls' in response: + for tool_call in response['tool_calls']: + if 'function' in tool_call: + arguments = tool_call['function'].get('arguments', {}) + if isinstance(arguments, str): + arguments = _parse_json_string(arguments) + if arguments: + expected_output = arguments.get('expected_output', '') + return _extract_and_return(expected_output, "OpenAI tool_calls") + + elif 'function_call' in response: + arguments = response['function_call'].get('arguments', {}) + if isinstance(arguments, str): + arguments = _parse_json_string(arguments) + if arguments: + expected_output = arguments.get('expected_output', '') + return _extract_and_return(expected_output, "OpenAI function_call") + + elif isinstance(response, str): + print("DEBUG: Found string response, trying to parse JSON") + if 'get_state_information' in response: + import re + json_match = re.search(r'\{.*\}', response, re.DOTALL) + if json_match: + parsed = _parse_json_string(json_match.group()) + if parsed: + expected_output = parsed.get('expected_output', '') + return _extract_and_return(expected_output, "string parsing") + + except Exception as e: + print(f"DEBUG: Error extracting expected_output: {e}") + logger.error(f"Error extracting expected_output: {e}") + + print("DEBUG: No expected_output found, returning None") + return None + + def _handle_function_call(self, response, chat_session, chunks, **kwargs): + """Handle function call for guided guest""" + + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + skip_next_stage = kwargs.get('skip_next_stage', False) + skip_next_stage_preprocessing = kwargs.get('skip_next_stage_preprocessing', False) + target_stage = kwargs.get('target_stage') + modified_bot_question = kwargs.get('modified_bot_question') + if skip_next_stage: + if target_stage and isinstance(target_stage, int): + if skip_next_stage_preprocessing: + chat_session.current_step = target_stage + 1 + logger.info( + f"Skipping target stage {target_stage} due to preprocessing, moving to {target_stage + 1}") + else: + chat_session.current_step = target_stage + else: + chat_session.current_step += 2 + + elif skip_next_stage_preprocessing: + chat_session.current_step += 2 + logger.info(f"Skipping next stage {chat_session.current_step - 1} due to preprocessing") + + else: + chat_session.current_step += 1 + + total_steps = CompanyStateMachine.objects.filter( + company_bot=company_bot + ).count() + + if chat_session.current_step >= total_steps: + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save() + + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + if not state_machine: + return None + + if modified_bot_question: + bot_question = modified_bot_question + logger.info(f"Using modified bot_question from preprocessing: {bot_question[:100]}") + else: + bot_question = state_machine.bot_question + + # Check if this is a NON_LLM state - if so, just use bot_question from DB + is_non_llm = False + if hasattr(state_machine, 'operation_type'): + from chatbot.models.enums import OperationTypeChoices + if state_machine.operation_type == OperationTypeChoices.NON_LLM: + is_non_llm = True + logger.info(f"State {state_machine.name} is NON_LLM, using bot_question from DB") + + chat_status = self.get_chat_status(state_machine=state_machine, company_bot=company_bot) + + print("sending bot_question: ", bot_question) + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + other_params = {'function_call_response': response} + if is_non_llm: + other_params.update({ + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + }) + print(f"DEBUG: Saving function call response in other_params: {response}") + stage = state_machine.name if state_machine else None + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=chunks, + status=chat_status, translated_message=translated_message, stage=stage, + other_params=other_params + ) + + return response + + def _handle_regular_response(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, current_step, reason=None, + streaming_completed=False, **kwargs): + """Handle regular response for guided guest""" + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + extra_content = None + + print("[_handle_regular_response] Response: ", response) + + response, extra_content = self._handle_response_extra_content( + response=response, company_bot=company_bot + ) + + if streaming_completed: + print("Streaming already completed - skipping duplicate processing") + logger.info("Streaming already completed - skipping duplicate processing") + return None + + translated_message = self.translate_message( + message=response, channel_name=channel_name, step_number=current_step, + language=language, company_bot=company_bot, extra_content=extra_content + ) + + other_params = {} + if reason: + other_params['reason'] = reason + print(f"DEBUG: Adding reason to other_params: {reason}") + + stage = state_machine.name if state_machine else None + if response and str(response).strip(): + message_to_save = response + elif extra_content and extra_content.get("query") and str(extra_content.get("query")).strip(): + message_to_save = extra_content["query"] + else: + message_to_save = "Understood." + + self.save_message( + session_id=session_id, profile_id=profile_id, message=message_to_save, chunks=chunks, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=stage, + other_params=other_params if other_params else None + ) + + return response + + def _handle_response_extra_content(self, response, company_bot): + extra_content = None + if company_bot.bot_type == CompanyBotTypeChoices.SIMPLE: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + print("Updated response post clean: ", response) + logger.info(f"Updated response post clean: {response}") + if response and isinstance(response, dict): + query = response.get("query", "") + + extra_content = { + "query": query, + "should_move_forward": response.get("should_move_forward", 'no'), + "validation": response.get("validation", "") + } + import json_repair + tag_context = company_bot.tag_context + if tag_context: + tag_context = json_repair.repair_json(tag_context, return_objects=True) + + message = response.get("message", "") + validation = response.get("validation") + + if response.get("should_move_forward") == 'yes': + message = '' + elif tag_context and validation: + message = tag_context.get(validation, message) + + response = message + + return response, extra_content + + def _handle_freeflow_function_call(self, response, chat_session, chunks, **kwargs): + """Handle function calls for FREE_FLOW bots (like download_file)""" + + # Extract required parameters from kwargs + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + + logger.info(f"Processing FREE_FLOW function call for session {session_id}") + print(f"DEBUG: _handle_freeflow_function_call called with response: {response}") + + function_call_data = response.get('function_call', {}) + function_name = function_call_data.get('name') + arguments_str = function_call_data.get('arguments', '{}') + + # Parse arguments if it's a string + if isinstance(arguments_str, str): + try: + arguments = json.loads(arguments_str) + except: + import json_repair + arguments = json_repair.repair_json(arguments_str, return_objects=True) + else: + arguments = arguments_str + + logger.info(f"Function: {function_name}, Arguments: {arguments}") + print(f"DEBUG: Function: {function_name}, Arguments: {arguments}") + + # Handle download_file function + if function_name == 'download_file': + filename = arguments.get('filename', 'download.pdf') + content_type = arguments.get('content_type', 'pdf') + + # Get the file search content and sources from response + file_search_content = response.get('content', '') + extra_content_data = response.get('extra_content', {}) + sources = extra_content_data.get('sources', []) + + # Get the content parameter from function arguments (contains explanation from file_search) + content_from_args = arguments.get('content', '') + + logger.info(f"Download request - filename: {filename}, type: {content_type}") + logger.info(f"Available sources: {len(sources)}") + logger.info(f"📄 File search content length: {len(file_search_content)} chars") + logger.info(f"📝 Content from function args: {len(content_from_args)} chars") + logger.info(f"🎯 Function call detected: {function_name}") + + # Create and upload file to S3 + file_result = create_and_upload_file( + content=content_from_args, + filename=filename, + company_bot_id=company_bot.id, + session_id=session_id + ) + + # Check if file creation was successful + file_url = None + if file_result.get('success'): + file_url = file_result.get('media_url') + logger.info(f"✅ File uploaded successfully: {file_url}") + print(f"DEBUG: File uploaded successfully: {file_url}") + else: + logger.error(f"❌ File upload failed: {file_result.get('error')}") + print(f"DEBUG: File upload failed: {file_result.get('error')}") + + # Use content from function arguments as the main message (contains the explanation) + # If content is available, show it; otherwise use a default message + bot_message = arguments.get( + "bot_message", + f"Your file '{filename}' is ready. You can download it below." + ) + + logger.info(f"Sending download acknowledgment with {len(bot_message)} chars") + logger.info(f"Function call detected internally: {function_name} with args: {arguments.keys()}") + logger.info(f"File search content available: {len(response.get('content', ''))} chars") + + # Prepare extra_content with sources and file_url (standardized format) + extra_content_to_send = {} + if sources: + extra_content_to_send['sources'] = sources + if file_url: + extra_content_to_send['file_url'] = file_url + logger.info(f"📎 Adding file_url to extra_content: {file_url}") + + # Include sources and file_url in extra_content for frontend display + translated_message = translate_and_send_message( + accumulated_message=bot_message, + current_channel_name=channel_name, + current_step_number=chat_session.current_step, + finish_reason="stop", # Use 'stop' instead of 'function_call' to hide function call from frontend + route=language, + company_bot=company_bot, + extra_content=extra_content_to_send if extra_content_to_send else None + ) + + # Save to database with function call metadata for internal tracking + save_in_company_db( + session_id=session_id, + profile_id=profile_id, + initiated_by='AI', + message=bot_message, + chunks=None, + status=ChatStatus.IN_PROGRESS, + translated_message=translated_message, + stage=None, + other_params={ + 'function_call': function_name, + 'arguments': arguments, + 'file_search_content': response.get('content', ''), # Store file_search content + 'sources': response.get('extra_content', {}).get('sources', []), + 'file_url': file_url, # Store the uploaded file URL + 'file_result': file_result # Store full upload result for debugging + } + ) + + return bot_message + else: + logger.warning(f"Unknown function call: {function_name}") + return self.default_error_message diff --git a/chatbot/services/response_handlers/common_handler_new.py b/chatbot/services/response_handlers/common_handler_new.py new file mode 100644 index 0000000..38bfff6 --- /dev/null +++ b/chatbot/services/response_handlers/common_handler_new.py @@ -0,0 +1,735 @@ +from chatbot.models import ChatStatus, CompanyChat, CompanyBotTypeChoices, LLMProvider, BotVernacular +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.response_handlers.base_response_handler_new import BaseResponseHandlerNew +from chatbot.utils.shiksha_chaupal.date_utils import handle_date_prompt +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +import logging +import json +from json_repair import repair_json + +logger = logging.getLogger('django') + + +class CommonResponseHandlerNew(BaseResponseHandlerNew): + """Common Response handler for bot""" + + def check_early_return(self, chat_session, **kwargs): + """Check for EVENT state early return""" + company_bot = kwargs['company_bot'] + + try: + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + if state_machine and state_machine.name == 'EVENT_DATE': + print("Here inside") + return self._handle_event_state(chat_session=chat_session, state_machine=state_machine, **kwargs) + except Exception as e: + print("Error: ", e) + logger.error(f"Error in check_early_return: {e}") + + return None + + def _handle_event_state(self, chat_session, state_machine, **kwargs): + """Handle EVENT state with date prompt""" + intro_mssg = kwargs.get('intro_mssg') + profile = kwargs.get('profile') + other_info = kwargs.get('other_info') + channel_name = kwargs['channel_name'] + language = kwargs['language'] + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + profile_id = kwargs['profile_id'] + print("Before date fun") + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + bot_question = handle_date_prompt( + intro_mssg=intro_mssg, + profile=profile, + company_chats=company_chats, + other_info=other_info + ) + print("DATE RES: ", bot_question) + if bot_question is None: + bot_question = self.default_error_message + + if bot_question == '': + return { + "toolUseId": "tooluse_auto_advance", + "name": "get_state_information", + "input": { + "next_state_name": 'AUTO', + "reason": "Date parsed successfully" + } + } + else: + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + stage = state_machine.name if state_machine else None + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=None, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=stage + ) + + return bot_question + + def get_messages_for_llm(self, **kwargs): + """Use temp_messages if available, otherwise original messages""" + temp_messages = kwargs.get('temp_messages') + messages = kwargs.get('messages') + return temp_messages if temp_messages else messages + + def _send_db_question(self, bot_question, chat_session, chunks, **kwargs): + """Send bot question from database for NON_LLM operations""" + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + + modified_bot_question = kwargs.get('modified_bot_question') + if modified_bot_question: + bot_question = modified_bot_question + logger.info(f"Using modified bot_question from preprocessing: {bot_question[:100]}") + + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + except CompanyStateMachine.DoesNotExist: + logger.error(f"State machine not found for step {chat_session.current_step}") + return self.default_error_message + + chat_status = self.get_chat_status( + state_machine=state_machine, company_bot=company_bot + ) + + # Translate and send to WebSocket + translated_message = self.translate_message( + message=bot_question, + channel_name=channel_name, + step_number=chat_session.current_step, + language=language, + company_bot=company_bot + ) + + # Save to database with metadata indicating this is a NON_LLM question from DB + self.save_message( + session_id=session_id, + profile_id=profile_id, + message=bot_question, + chunks=chunks, + status=chat_status, + translated_message=translated_message, + stage=state_machine.name, + other_params={ + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + } + ) + + logger.info(f"Sent NON_LLM bot question from DB for state: {state_machine.name}") + return bot_question + + + def is_function_call(self, response): + """Override to handle empty responses as function calls""" + if super().is_function_call(response): + return True + + # Check for OpenAI Responses API function call format + if isinstance(response, dict): + if response.get('finish_reason') == 'function_call' and 'function_call' in response: + print("DEBUG: Detected OpenAI Responses API function call format") + logger.info("Detected OpenAI Responses API function call format") + return True + + if response.get('should_function_call', False): + print("DEBUG: should_function_call is True, treating as function call") + logger.info("should_function_call flag is True, treating as function call") + return True + + try: + extracted_response, _, meta = self._extract_response_and_reason(response) + if meta: + flag = meta.get("should_function_call") + if isinstance(flag, str): + flag = flag.strip().lower() in ("true", "yes", "1") + if flag is True: + print("DEBUG: should_function_call detected via meta in is_function_call") + logger.info("should_function_call detected via meta in is_function_call") + return True + if extracted_response == '': + print( + "DEBUG: Empty response detected in is_function_call, treating as function call for postprocessing") + return True + except Exception as e: + print(f"DEBUG: Error in is_function_call extraction: {e}") + logger.error(f"Error in is_function_call extraction: {e}") + + return False + + def _analyze_response(self, response): + """ + Analyze response to determine if it's a function call and extract content. + """ + is_actual_function_call = self.is_function_call(response=response) + + if is_actual_function_call: + return True, None, None + + extracted_response, reason_text, meta = self._extract_response_and_reason(response) + + if meta: + flag = meta.get("should_function_call") + if isinstance(flag, str): + flag = flag.strip().lower() in ("true", "yes", "1") + if flag is True: + print("DEBUG: should_function_call detected via meta in _analyze_response") + logger.info("should_function_call detected via meta in _analyze_response") + return True, None, None + + if extracted_response == '': + print("DEBUG: Empty response detected after extraction, treating as function call for state transition") + return True, extracted_response, reason_text + + return False, extracted_response, reason_text + + def analyze_response_for_postprocessing(self, response): + """Override to handle empty responses as function calls for postprocessing""" + is_function_call, _, _ = self._analyze_response(response) + return is_function_call + + def process_response(self, response, chat_session, chunks, streaming_completed=False, **kwargs): + """Process common response with retry logic for short responses""" + print(f"DEBUG: Starting process_response with response type: {type(response)}") + print(f"DEBUG: Response preview: {str(response)[:200]}...") + print(f"DEBUG: kwargs keys: {list(kwargs.keys())}") + print(f"DEBUG: streaming_completed in kwargs: {'streaming_completed' in kwargs}") + print(f"DEBUG: streaming_completed value: {kwargs.get('streaming_completed', 'NOT SET')}") + + retry_attempt = kwargs.get('retry_attempt', 0) + print(f"DEBUG: Current retry attempt: {retry_attempt}") + + skip_llm_call = kwargs.get('skip_llm', False) + send_bot_question = kwargs.get('send_bot_question', False) + print(f"DEBUG: skip_llm_call: {skip_llm_call}") + + # Handle NON_LLM operation type - send bot_question directly from database + if send_bot_question and skip_llm_call: + bot_question = kwargs.get('bot_question_from_db') + if bot_question: + return self._send_db_question( + bot_question=bot_question, + chat_session=chat_session, + chunks=chunks, + **kwargs + ) + + current_step = chat_session.current_step + + if skip_llm_call: + is_function_call = True + expected_output_response = None + reason_text = None + print("DEBUG: Skipping LLM call, treating as function call") + else: + is_function_call, expected_output_response, reason_text = self._analyze_response(response) + print(f"DEBUG: Analysis result - is_function_call: {is_function_call}") + print(f"DEBUG: expected_output_response: '{expected_output_response}'") + print(f"DEBUG: reason_text: '{reason_text}'") + + if not is_function_call and retry_attempt < self.max_retry_attempts: + response_to_check = expected_output_response if expected_output_response is not None else response + + if self._is_response_too_short(response_to_check): + logger.info( + f"Response too short, retrying LLM call (attempt {retry_attempt + 1}/{self.max_retry_attempts})") + print( + f"DEBUG: Response too short, retrying LLM call (attempt {retry_attempt + 1}/{self.max_retry_attempts})") + + kwargs['retry_attempt'] = retry_attempt + 1 + + try: + result = self.get_llm_response(**kwargs) + + if isinstance(result, tuple): + new_response, extra_content, finish_reason = result + else: + new_response = result + finish_reason = None + + if new_response: + logger.info("Successfully got new response from LLM on retry") + print("DEBUG: Successfully got new response from LLM on retry") + + return self.process_response( + new_response, chat_session, chunks, + streaming_completed=streaming_completed, + **kwargs + ) + else: + logger.info("LLM returned None on retry, will use error message") + print("DEBUG: LLM returned None on retry, will use error message") + kwargs['use_error_message'] = True + + except Exception as e: + logger.error(f"Error during LLM retry: {e}") + print(f"DEBUG: Error during LLM retry: {e}") + kwargs['use_error_message'] = True + + if not is_function_call and retry_attempt >= self.max_retry_attempts: + response_to_check = expected_output_response if expected_output_response is not None else response + if self._is_response_too_short(response_to_check): + logger.info("Exhausted all retries, response still too short, will use error message") + print("DEBUG: Exhausted all retries, response still too short, will use error message") + kwargs['use_error_message'] = True + + company_bot = kwargs['company_bot'] + language = kwargs['language'] + + forward_kwargs = kwargs.copy() + + forward_kwargs['messages'] = self.get_messages_for_llm(**kwargs) + forward_kwargs['skip_next_stage'] = kwargs.get('skip_next_stage', False) + forward_kwargs['target_stage'] = kwargs.get('target_stage', False) + forward_kwargs['skip_next_stage_preprocessing'] = kwargs.get('skip_next_stage_preprocessing', False) + + if kwargs.get('use_error_message', False) and not is_function_call: + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if ( + bot_vernacular and bot_vernacular.error_message) else "Please try again!" + logger.info(f"Using error message: {error_message}") + print(f"DEBUG: Using error message: {error_message}") + expected_output_response = error_message + response = error_message + + # Handle function calls for STATE_MACHINE bots + if is_function_call and company_bot and company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + print("DEBUG: Processing as STATE_MACHINE function call") + return self._handle_function_call( + response=response, chat_session=chat_session, chunks=chunks, **forward_kwargs + ) + else: + print("DEBUG: Processing as regular response") + final_response = expected_output_response if ( + expected_output_response is not None and expected_output_response != "") else response + return self._handle_regular_response( + response=final_response, chat_session=chat_session, chunks=chunks, current_step=current_step, + streaming_completed=streaming_completed, reason=reason_text, **kwargs + ) + + def _extract_response_and_reason(self, response): + """Extract both response and reason from the response""" + logger.info(f"DEBUG: Extracting response and reason from response type: {type(response)}") + logger.info(f"DEBUG: Response content preview: {str(response)[:200]}...") + + try: + if isinstance(response, str): + logger.info("DEBUG: Response is string") + + stripped = response.strip() + if not (stripped.startswith('{') and stripped.endswith('}')): + if ('"response"' in stripped or '"reason"' in stripped) and ':' in stripped: + logger.info("DEBUG: String looks like JSON without outer braces, adding them") + response = '{' + stripped + '}' + logger.info(f"DEBUG: Fixed JSON string: {response[:200]}...") + + if not (response.strip().startswith('{') and response.strip().endswith('}')): + logger.info("DEBUG: String doesn't look like JSON, treating as plain text response") + return response, None, None + + logger.info("DEBUG: String looks like JSON, attempting parsing") + try: + parsed_response = json.loads(response) + logger.info("DEBUG: Successfully parsed JSON from string") + except json.JSONDecodeError: + try: + repaired_json = repair_json(response) + parsed_response = json.loads(repaired_json) + logger.info("DEBUG: Successfully repaired and parsed JSON") + except Exception as repair_error: + logger.info(f"DEBUG: JSON repair failed: {repair_error}") + return response, None, None + + response = parsed_response + + if isinstance(response, dict): + logger.info("DEBUG: Processing dict response") + logger.info(f"DEBUG: Dict keys: {list(response.keys())}") + + response_copy = response.copy() + extracted_data = None + + if 'toolUseId' in response_copy and 'input' in response_copy: + logger.info("DEBUG: Found direct tool use format (toolUseId + input)") + extracted_data = response_copy.get('input') + + elif 'name' in response_copy and 'parameters' in response_copy: + logger.info("DEBUG: Found simple function call format (name + parameters)") + extracted_data = response_copy.get('parameters') + + elif 'parameters' in response_copy: + logger.info("DEBUG: Found parameters format") + extracted_data = response_copy.get('parameters') + + elif 'input' in response_copy: + logger.info("DEBUG: Found input format") + extracted_data = response_copy.get('input') + + elif 'output' in response_copy and 'message' in response_copy['output']: + logger.info("DEBUG: Found Bedrock nested response format") + content = response_copy['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + extracted_data = item['toolUse'].get('input', {}) + break + + elif 'function_call' in response_copy: + logger.info("DEBUG: Found OpenAI function_call format") + arguments = response_copy['function_call'].get('arguments', {}) + if isinstance(arguments, str): + try: + extracted_data = json.loads(arguments) + except json.JSONDecodeError: + try: + repaired_arguments = repair_json(arguments) + extracted_data = json.loads(repaired_arguments) + except Exception: + extracted_data = None + else: + extracted_data = arguments + + elif 'tool_calls' in response_copy: + logger.info("DEBUG: Found OpenAI tool_calls format") + tool_calls = response_copy['tool_calls'] + if tool_calls and len(tool_calls) > 0: + tool_call = tool_calls[0] + if 'function' in tool_call: + arguments = tool_call['function'].get('arguments', {}) + if isinstance(arguments, str): + try: + extracted_data = json.loads(arguments) + except json.JSONDecodeError: + try: + repaired_arguments = repair_json(arguments) + extracted_data = json.loads(repaired_arguments) + except Exception: + extracted_data = None + else: + extracted_data = arguments + else: + logger.info("DEBUG: Checking if response/reason are directly in dict") + if 'response' in response_copy or 'reason' in response_copy: + extracted_data = response_copy + + logger.info(f"DEBUG: Extracted data type: {type(extracted_data)}") + if extracted_data: + logger.info( + f"DEBUG: Extracted data keys: {list(extracted_data.keys()) if isinstance(extracted_data, dict) else 'Not a dict'}") + + meta = extracted_data.copy() if isinstance(extracted_data, dict) else None + if extracted_data and isinstance(extracted_data, dict): + if len(extracted_data) == 0: + print( + f"DEBUG: Empty extracted_data detected from LLM (input/parameters) - treating as function call") + logger.info( + f"Empty extracted_data detected from LLM (input/parameters) - treating as function call: {response}") + return '', ('LLM returned empty input/parameters - treating as function call to ' + 'proceed to next state'), meta + + response_text = extracted_data.get('response', '') + reason_text = extracted_data.get('reason', '') + logger.info( + f"DEBUG: Final extraction - response: '{response_text[:100]}...', " + f"reason: '{reason_text[:100]}...'") + return response_text, reason_text, meta + + elif extracted_data and isinstance(extracted_data, str): + logger.info("DEBUG: Extracted data is string, trying to parse as JSON") + try: + parsed_data = json.loads(extracted_data) + except json.JSONDecodeError: + try: + repaired_data = repair_json(extracted_data) + parsed_data = json.loads(repaired_data) + logger.info("DEBUG: Successfully repaired string data") + except Exception as e: + logger.info(f"DEBUG: Failed to parse string data: {e}") + return extracted_data, None, meta + + if isinstance(parsed_data, dict): + response_text = parsed_data.get('response', '') + reason_text = parsed_data.get('reason', '') + logger.info( + f"DEBUG: Parsed string data - response: '{response_text[:100]}...', reason: " + f"'{reason_text[:100]}...'") + meta = parsed_data.copy() + return response_text, reason_text, meta + + logger.info("DEBUG: No specific format matched, checking original dict for response/reason") + response_text = response_copy.get('response', '') + reason_text = response_copy.get('reason', '') + if response_text or reason_text: + logger.info( + f"DEBUG: Found in original dict - response: '{response_text[:100]}...', reason: " + f"'{reason_text[:100]}...'" + ) + return response_text, reason_text, meta + + except Exception as e: + logger.info(f"DEBUG: Error extracting response and reason: {e}") + logger.error(f"Error extracting response and reason: {e}") + + logger.info("DEBUG: Fallback - returning original response as string") + final_response = str(response) if not isinstance(response, str) else response + return final_response, None, None + + def _extract_expected_output(self, response): + """Extract expected_output from function call response if it exists and is not empty""" + print(f"DEBUG: Extracting expected_output from response type: {type(response)}") + print(f"DEBUG: Response content: {response}") + + def _extract_and_return(expected_output, format_name): + """Helper to extract and return expected_output if not empty""" + print(f"DEBUG: Expected output from {format_name}: '{expected_output}'") + return expected_output if expected_output else None + + def _parse_json_string(json_str): + """Helper to safely parse JSON string""" + try: + import json + return json.loads(json_str) + except json.JSONDecodeError: + return None + + try: + if isinstance(response, dict): + if 'name' in response and 'parameters' in response: + print("DEBUG: Found simple function call format (NEW)") + expected_output = response['parameters'].get('expected_output', '') + return _extract_and_return(expected_output, "simple function call") + + elif 'toolUseId' in response and 'input' in response: + print("DEBUG: Found direct tool use format (OLD)") + expected_output = response['input'].get('expected_output', '') + return _extract_and_return(expected_output, "direct tool use") + + elif 'output' in response and 'message' in response['output']: + print("DEBUG: Found Bedrock response format") + content = response['output']['message'].get('content', []) + for item in content: + if 'toolUse' in item: + expected_output = item['toolUse'].get('input', {}).get('expected_output', '') + return _extract_and_return(expected_output, "Bedrock format") + + elif 'function_call' in response or 'tool_calls' in response: + print("DEBUG: Found OpenAI-style function call format") + + if 'tool_calls' in response: + for tool_call in response['tool_calls']: + if 'function' in tool_call: + arguments = tool_call['function'].get('arguments', {}) + if isinstance(arguments, str): + arguments = _parse_json_string(arguments) + if arguments: + expected_output = arguments.get('expected_output', '') + return _extract_and_return(expected_output, "OpenAI tool_calls") + + elif 'function_call' in response: + arguments = response['function_call'].get('arguments', {}) + if isinstance(arguments, str): + arguments = _parse_json_string(arguments) + if arguments: + expected_output = arguments.get('expected_output', '') + return _extract_and_return(expected_output, "OpenAI function_call") + + elif isinstance(response, str): + print("DEBUG: Found string response, trying to parse JSON") + if 'get_state_information' in response: + import re + json_match = re.search(r'\{.*\}', response, re.DOTALL) + if json_match: + parsed = _parse_json_string(json_match.group()) + if parsed: + expected_output = parsed.get('expected_output', '') + return _extract_and_return(expected_output, "string parsing") + + except Exception as e: + print(f"DEBUG: Error extracting expected_output: {e}") + logger.error(f"Error extracting expected_output: {e}") + + print("DEBUG: No expected_output found, returning None") + return None + + def _handle_function_call(self, response, chat_session, chunks, **kwargs): + """Handle function call for guided guest""" + + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + skip_next_stage = kwargs.get('skip_next_stage', False) + skip_next_stage_preprocessing = kwargs.get('skip_next_stage_preprocessing', False) + target_stage = kwargs.get('target_stage') + modified_bot_question = kwargs.get('modified_bot_question') + if skip_next_stage: + if target_stage and isinstance(target_stage, int): + if skip_next_stage_preprocessing: + chat_session.current_step = target_stage + 1 + logger.info( + f"Skipping target stage {target_stage} due to preprocessing, moving to {target_stage + 1}") + else: + chat_session.current_step = target_stage + else: + chat_session.current_step += 2 + + elif skip_next_stage_preprocessing: + chat_session.current_step += 2 + logger.info(f"Skipping next stage {chat_session.current_step - 1} due to preprocessing") + + else: + chat_session.current_step += 1 + + total_steps = CompanyStateMachine.objects.filter( + company_bot=company_bot + ).count() + + if chat_session.current_step >= total_steps: + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save() + + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + if not state_machine: + return None + + if modified_bot_question: + bot_question = modified_bot_question + logger.info(f"Using modified bot_question from preprocessing: {bot_question[:100]}") + else: + bot_question = state_machine.bot_question + + # Check if this is a NON_LLM state - if so, just use bot_question from DB + is_non_llm = False + if hasattr(state_machine, 'operation_type'): + from chatbot.models.enums import OperationTypeChoices + if state_machine.operation_type == OperationTypeChoices.NON_LLM: + is_non_llm = True + logger.info(f"State {state_machine.name} is NON_LLM, using bot_question from DB") + + chat_status = self.get_chat_status(state_machine=state_machine, company_bot=company_bot) + + print("sending bot_question: ", bot_question) + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + other_params = {'function_call_response': response} + if is_non_llm: + other_params.update({ + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + }) + print(f"DEBUG: Saving function call response in other_params: {response}") + stage = state_machine.name if state_machine else None + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=chunks, + status=chat_status, translated_message=translated_message, stage=stage, + other_params=other_params + ) + + return response + + def _handle_regular_response(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, current_step, reason=None, + streaming_completed=False, **kwargs): + """Handle regular response for guided guest""" + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + extra_content = None + + print("[_handle_regular_response] Response: ", response) + + response, extra_content = self._handle_response_extra_content( + response=response, company_bot=company_bot + ) + + if streaming_completed: + print("Streaming already completed - skipping duplicate processing") + logger.info("Streaming already completed - skipping duplicate processing") + return None + + translated_message = self.translate_message( + message=response, channel_name=channel_name, step_number=current_step, + language=language, company_bot=company_bot, extra_content=extra_content + ) + + other_params = {} + if reason: + other_params['reason'] = reason + print(f"DEBUG: Adding reason to other_params: {reason}") + + stage = state_machine.name if state_machine else None + if response and str(response).strip(): + message_to_save = response + elif extra_content and extra_content.get("query") and str(extra_content.get("query")).strip(): + message_to_save = extra_content["query"] + else: + message_to_save = "Understood." + + self.save_message( + session_id=session_id, profile_id=profile_id, message=message_to_save, chunks=chunks, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=stage, + other_params=other_params if other_params else None + ) + + return response + + def _handle_response_extra_content(self, response, company_bot): + extra_content = None + if company_bot.bot_type == CompanyBotTypeChoices.SIMPLE: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + print("Updated response post clean: ", response) + logger.info(f"Updated response post clean: {response}") + if response and isinstance(response, dict): + query = response.get("query", "") + + extra_content = { + "query": query, + "should_move_forward": response.get("should_move_forward", 'no'), + "validation": response.get("validation", "") + } + import json_repair + tag_context = company_bot.tag_context + if tag_context: + tag_context = json_repair.repair_json(tag_context, return_objects=True) + + message = response.get("message", "") + validation = response.get("validation") + + if response.get("should_move_forward") == 'yes': + message = '' + elif tag_context and validation: + message = tag_context.get(validation, message) + + response = message + + return response, extra_content diff --git a/chatbot/services/response_handlers/discussion_guest_handler.py b/chatbot/services/response_handlers/discussion_guest_handler.py new file mode 100644 index 0000000..e3182b5 --- /dev/null +++ b/chatbot/services/response_handlers/discussion_guest_handler.py @@ -0,0 +1,267 @@ +from pyexpat.errors import messages + +from chatbot.models import ChatStatus, Profile, CompanyChat +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.response_handlers.base_response_handler import BaseResponseHandler +from chatbot.utils.chaupal_question import get_chaupal_challenge_response, get_chaupal_solution_response +from chatbot.utils.shiksha_chaupal.date_utils import handle_date_prompt +import logging + +logger = logging.getLogger('django') + + +class GuestDiscussionResponseHandler(BaseResponseHandler): + """Response handler for guest discussion bot""" + + def check_early_return(self, chat_session, **kwargs): + """Check for EVENT state early return""" + company_bot = kwargs['company_bot'] + + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + print("Current step in early func: ", state_machine.name) + # Special handling for EVENT state + if state_machine and state_machine.name == 'EVENT': + print("Here inside") + return self._handle_event_state(chat_session=chat_session, state_machine=state_machine, **kwargs) + except Exception as e: + print("Error: ", e) + logger.error(f"Error in check_early_return: {e}") + + return None + + def _handle_event_state(self, chat_session, state_machine, **kwargs): + """Handle EVENT state with date prompt""" + intro_mssg = kwargs.get('intro_mssg') + profile = kwargs.get('profile') + other_info = kwargs.get('other_info') + channel_name = kwargs['channel_name'] + language = kwargs['language'] + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + profile_id = kwargs['profile_id'] + print("Before date fun") + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + bot_question = handle_date_prompt( + intro_mssg=intro_mssg, + profile=profile, + company_chats=company_chats, + other_info=other_info + ) + print("DATE RES: ", bot_question) + if bot_question is None: + bot_question = self.default_error_message + + if bot_question == '': + # Return special flag to skip LLM call + return {'skip_llm': True} + else: + # Send the date prompt response + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=None, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=state_machine.name + ) + + return bot_question + + def get_messages_for_llm(self, **kwargs): + """Use temp_messages if available, otherwise original messages""" + temp_messages = kwargs.get('temp_messages') + messages=kwargs.get('messages') + return temp_messages if temp_messages else messages + + def _send_db_question(self, bot_question, chat_session, chunks, **kwargs): + """Send bot question from database for NON_LLM operations""" + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + channel_name = kwargs['channel_name'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + except CompanyStateMachine.DoesNotExist: + logger.error(f"State machine not found for step {chat_session.current_step}") + return self.default_error_message + + chat_status = self.get_chat_status( + state_machine=state_machine, company_bot=company_bot + ) + + # Translate and send to WebSocket + translated_message = self.translate_message( + message=bot_question, + channel_name=channel_name, + step_number=chat_session.current_step, + language=language, + company_bot=company_bot + ) + + # Save to database with metadata indicating this is a NON_LLM question from DB + self.save_message( + session_id=session_id, + profile_id=profile_id, + message=bot_question, + chunks=chunks, + status=chat_status, + translated_message=translated_message, + stage=state_machine.name, + other_params={ + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + } + ) + + logger.info(f"Sent NON_LLM bot question from DB for state: {state_machine.name}") + return bot_question + + + def process_response(self, response, chat_session, chunks, **kwargs): + """Process guest discussion response""" + skip_llm_call = kwargs.get('skip_llm', False) + send_bot_question = kwargs.get('send_bot_question', False) + + # Handle NON_LLM operation type - send bot_question directly from database + if send_bot_question and skip_llm_call: + bot_question = kwargs.get('bot_question_from_db') + if bot_question: + return self._send_db_question( + bot_question=bot_question, + chat_session=chat_session, + chunks=chunks, + **kwargs + ) + + current_step = chat_session.current_step + if skip_llm_call: + is_function_call = True + else: + is_function_call = self.is_function_call(response=response) + + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + channel_name = kwargs['channel_name'] + skip_next_stage=kwargs.get('skip_next_stage', False) + target_stage=kwargs.get('target_stage', False) + chat_messages=self.get_messages_for_llm(**kwargs) + + if is_function_call: + return self._handle_function_call( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, messages=chat_messages, skip_next_stage=skip_next_stage, target_stage=target_stage + ) + else: + return self._handle_regular_response( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, current_step=current_step + ) + + + def _handle_function_call(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, chunks, messages, skip_next_stage, + target_stage): + """Handle function call for guided guest""" + if skip_next_stage: + if target_stage and isinstance(target_stage, int): + chat_session.current_step = target_stage + else: + chat_session.current_step += 2 + else: + chat_session.current_step += 1 + chat_session.save() + + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + bot_question = state_machine.bot_question + + # Check if this is a NON_LLM state - if so, just use bot_question from DB + is_non_llm = False + if hasattr(state_machine, 'operation_type'): + from chatbot.models.enums import OperationTypeChoices + if state_machine.operation_type == OperationTypeChoices.NON_LLM: + is_non_llm = True + logger.info(f"State {state_machine.name} is NON_LLM, using bot_question from DB") + + # Only process CHALLENGES and SOLUTIONS for LLM states + if not is_non_llm: + if state_machine.name == 'CHALLENGES': + challenge_res = get_chaupal_challenge_response(messages=messages) + if challenge_res and isinstance(challenge_res, str): + bot_question = challenge_res + else: + bot_question = self.default_error_message + + elif state_machine.name == 'SOLUTIONS': + solution_res = get_chaupal_solution_response(messages=messages) + + if solution_res in ["", '', '""', None]: + # Skip to next state + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + bot_question = state_machine.bot_question + elif solution_res and isinstance(solution_res, str): + bot_question = solution_res + else: + bot_question = self.default_error_message + + chat_status = self.get_chat_status(state_machine=state_machine, company_bot=company_bot) + + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + # Prepare other_params with metadata + other_params = {} + if is_non_llm: + other_params = { + 'message_type': 'question', + 'operation_type': 'non_llm', + 'source': 'database' + } + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=chunks, + status=chat_status, translated_message=translated_message, stage=state_machine.name, + other_params=other_params if other_params else None + ) + + return response + + def _handle_regular_response(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, current_step): + """Handle regular response for guided guest""" + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + + translated_message = self.translate_message( + message=response, channel_name=channel_name, step_number=current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=response, chunks=chunks, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=state_machine.name + ) + + return response diff --git a/chatbot/services/response_handlers/guided_guest_handler.py b/chatbot/services/response_handlers/guided_guest_handler.py new file mode 100644 index 0000000..b461767 --- /dev/null +++ b/chatbot/services/response_handlers/guided_guest_handler.py @@ -0,0 +1,98 @@ +from chatbot.models import ChatStatus +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.response_handlers.base_response_handler import BaseResponseHandler + + +class GuidedGuestResponseHandler(BaseResponseHandler): + """Response handler for guided guest bot""" + + def check_early_return(self, chat_session, **kwargs): + """No early return for guided guest""" + return None + + def get_messages_for_llm(self, **kwargs): + """Use temp_messages if available, otherwise original messages""" + temp_messages = kwargs.get('temp_messages') + messages=kwargs.get('messages') + return temp_messages if temp_messages else messages + + def process_response(self, response, chat_session, chunks, **kwargs): + """Process guided guest response""" + skip_llm_call = kwargs.get('skip_llm', False) + + current_step = chat_session.current_step + if skip_llm_call: + is_func_call = True + else: + is_func_call = self.is_function_call(response=response) + + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + channel_name = kwargs['channel_name'] + target_stage=kwargs.get('target_stage', False) + skip_next_stage=kwargs.get('skip_next_stage', False) + + if is_func_call: + return self._handle_function_call( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, skip_next_stage=skip_next_stage, target_stage=target_stage + ) + else: + return self._handle_regular_response( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, current_step=current_step + ) + + def _handle_function_call(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, chunks, skip_next_stage, target_stage): + """Handle function call for guided guest""" + if skip_next_stage: + if target_stage and isinstance(target_stage, int): + chat_session.current_step = target_stage + else: + chat_session.current_step += 2 + else: + chat_session.current_step += 1 + chat_session.save() + + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + bot_question = state_machine.bot_question + chat_status = self.get_chat_status(state_machine=state_machine, company_bot=company_bot) + + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=chunks, + status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + + return response + + def _handle_regular_response(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, current_step): + """Handle regular response for guided guest""" + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + + translated_message = self.translate_message( + message=response, channel_name=channel_name, step_number=current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=response, chunks=chunks, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=state_machine.name + ) + + return response diff --git a/chatbot/services/response_handlers/handler_factory.py b/chatbot/services/response_handlers/handler_factory.py new file mode 100644 index 0000000..00b04cc --- /dev/null +++ b/chatbot/services/response_handlers/handler_factory.py @@ -0,0 +1,28 @@ +from chatbot.services.response_handlers.common_handler import CommonResponseHandler +from chatbot.services.response_handlers.discussion_guest_handler import GuestDiscussionResponseHandler +from chatbot.services.response_handlers.guided_guest_handler import GuidedGuestResponseHandler +from chatbot.services.response_handlers.oneshot_handler import OneShotResponseHandler + + +class ResponseHandlerFactory: + """Factory for creating response handlers""" + + _handlers = { + 'guided_guest': GuidedGuestResponseHandler, + 'oneshot': OneShotResponseHandler, + 'guest_discussion': GuestDiscussionResponseHandler, + 'common': CommonResponseHandler, + } + + @classmethod + def create_handler(cls, handler_type): + """Create response handler by type""" + handler_class = cls._handlers.get(handler_type) + if not handler_class: + raise ValueError(f"Unknown handler type: {handler_type}") + return handler_class() + + @classmethod + def register_handler(cls, handler_type, handler_class): + """Register new response handler""" + cls._handlers[handler_type] = handler_class diff --git a/chatbot/services/response_handlers/oneshot_handler.py b/chatbot/services/response_handlers/oneshot_handler.py new file mode 100644 index 0000000..51c501e --- /dev/null +++ b/chatbot/services/response_handlers/oneshot_handler.py @@ -0,0 +1,131 @@ +from chatbot.models import ChatStatus +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.response_handlers.base_response_handler import BaseResponseHandler + + +class OneShotResponseHandler(BaseResponseHandler): + """Response handler for oneshot bot""" + + def check_early_return(self, chat_session, **kwargs): + """Check if should return initial bot question""" + messages = kwargs['messages'] + intro_mssg = kwargs.get('intro_mssg') + remaining_stages = kwargs['remaining_stages'] + + # Early return condition for oneshot + if ((intro_mssg is None and len(messages) < 2) or + (intro_mssg is not None and len(messages) <= 3)): + return self._handle_initial_question( + chat_session=chat_session, remaining_stages=remaining_stages, session_id=kwargs['session_id'], + channel_name=kwargs['channel_name'], language=kwargs['language'], profile_id=kwargs['profile_id'], + company_bot=kwargs['company_bot'] + ) + return None + + def get_messages_for_llm(self, **kwargs): + """Use temp_messages if available, otherwise original messages""" + temp_messages = kwargs.get('temp_messages') + messages=kwargs.get('messages') + return temp_messages if temp_messages else messages + + def process_response(self, response, chat_session, chunks, **kwargs): + """Process oneshot response""" + skip_llm_call = kwargs.get('skip_llm', False) + + remaining_stages = kwargs['remaining_stages'] + channel_name = kwargs['channel_name'] + company_bot = kwargs['company_bot'] + session_id = kwargs['session_id'] + language = kwargs['language'] + profile_id = kwargs['profile_id'] + + current_step = chat_session.current_step + if skip_llm_call: + is_func_call = True + else: + is_func_call = self.is_function_call(response=response) + + if is_func_call and remaining_stages: + return self._handle_function_call( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, remaining_stages=remaining_stages + ) + else: + return self._handle_regular_response( + response=response, chat_session=chat_session, company_bot=company_bot, + session_id=session_id, channel_name=channel_name, language=language, profile_id=profile_id, + chunks=chunks, current_step=current_step + ) + + def _handle_initial_question(self, chat_session, remaining_stages, session_id, + channel_name, language, profile_id, company_bot): + """Handle initial bot question for oneshot""" + current_stage_name = remaining_stages[0] + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, name=current_stage_name + ) + bot_question = state_machine.bot_question + + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=[], + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=state_machine.name + ) + + return bot_question + + def _handle_function_call(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, remaining_stages): + """Handle function call for oneshot""" + remaining_stages.pop(0) + current_stage_name = remaining_stages[0] + + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, name=current_stage_name + ) + + # Update session + chat_session.session_context['remaining_stages'] = remaining_stages + chat_session.current_step = state_machine.step + chat_session.save() + + bot_question = state_machine.bot_question + chat_status = self.get_chat_status(state_machine=state_machine, company_bot=company_bot) + + translated_message = self.translate_message( + message=bot_question, channel_name=channel_name, step_number=chat_session.current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=bot_question, chunks=chunks, + status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + + return response + + def _handle_regular_response(self, response, chat_session, company_bot, + session_id, channel_name, language, profile_id, + chunks, current_step): + """Handle regular response for oneshot""" + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + + translated_message = self.translate_message( + message=response, channel_name=channel_name, step_number=current_step, + language=language, company_bot=company_bot + ) + + self.save_message( + session_id=session_id, profile_id=profile_id, message=response, chunks=chunks, + status=ChatStatus.IN_PROGRESS, translated_message=translated_message, stage=state_machine.name + ) + + return response diff --git a/chatbot/services/storage/__init__.py b/chatbot/services/storage/__init__.py new file mode 100644 index 0000000..346e6b2 --- /dev/null +++ b/chatbot/services/storage/__init__.py @@ -0,0 +1,13 @@ +""" +Storage services for handling file uploads to various cloud providers +""" +from .storage_factory import StorageFactory +from .base_storage_handler import BaseStorageHandler, UploadConfig, UploadResult + +__all__ = [ + 'StorageFactory', + 'BaseStorageHandler', + 'UploadConfig', + 'UploadResult', +] + diff --git a/chatbot/services/storage/aws_storage_handler.py b/chatbot/services/storage/aws_storage_handler.py new file mode 100644 index 0000000..0e806f4 --- /dev/null +++ b/chatbot/services/storage/aws_storage_handler.py @@ -0,0 +1,244 @@ +""" +AWS S3 storage handler implementation +""" +import os +import logging +import boto3 +from botocore.exceptions import ClientError +from typing import BinaryIO +from urllib.parse import urlparse +from .base_storage_handler import BaseStorageHandler, UploadConfig, UploadResult + +logger = logging.getLogger('django') + + +class AWSS3StorageHandler(BaseStorageHandler): + """ + AWS S3 implementation of the storage handler + Handles file uploads, deletions, and URL generation for S3 + """ + + def __init__(self, config: dict): + """ + Initialize AWS S3 storage handler + + Args: + config: Dictionary containing S3 configuration + Expected keys: bucket_name, region_name + """ + super().__init__(config) + self.bucket_name = config.get('bucket_name') or os.getenv('S3_BUCKET_NAME') + self.region_name = config.get('region_name') or os.getenv('AWS_REGION') + self.access_key = os.getenv('AWS_ACCESS_KEY_ID') + self.secret_key = os.getenv('AWS_SECRET_ACCESS_KEY') + + if not all([self.bucket_name, self.region_name]): + raise ValueError("AWS S3 configuration incomplete: bucket_name and region_name are required") + + self._client = None + + @property + def client(self): + """Lazy initialization of S3 client""" + if self._client is None: + self._client = boto3.client( + 's3', + region_name=self.region_name, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key + ) + return self._client + + def generate_presigned_url(self, upload_config: UploadConfig) -> UploadResult: + """ + Generate a presigned URL for S3 upload + + Args: + upload_config: Configuration for the upload operation + + Returns: + UploadResult with presigned URL and object details + """ + try: + object_key = self._generate_object_key(upload_config) + + params = { + 'Bucket': self.bucket_name, + 'Key': object_key, + 'ContentType': upload_config.file_type, + } + + # Add ACL if specified + if upload_config.acl: + params['ACL'] = upload_config.acl + + # Add metadata if provided + if upload_config.metadata: + params['Metadata'] = upload_config.metadata + + upload_url = self.client.generate_presigned_url( + 'put_object', + Params=params, + ExpiresIn=upload_config.expires_in + ) + + public_url = self.get_public_url(object_key) + s3_url = self.get_s3_url(object_key) + + logger.info(f"Generated presigned URL for S3: {object_key}") + + return UploadResult( + upload_url=upload_url, + object_key=object_key, + public_url=public_url, + object_url=s3_url, + success=True + ) + + except ClientError as e: + error_msg = f"Failed to generate presigned URL: {str(e)}" + logger.error(error_msg) + return UploadResult( + upload_url='', + object_key='', + public_url='', + object_url='', + success=False, + error=error_msg + ) + + def upload_file(self, file_obj: BinaryIO, upload_config: UploadConfig) -> UploadResult: + """ + Directly upload a file to S3 + + Args: + file_obj: File object to upload + upload_config: Configuration for the upload + + Returns: + UploadResult with upload details + """ + try: + object_key = self._generate_object_key(upload_config) + + extra_args = { + 'ContentType': upload_config.file_type, + } + + if upload_config.acl: + extra_args['ACL'] = upload_config.acl + + if upload_config.metadata: + extra_args['Metadata'] = upload_config.metadata + + self.client.upload_fileobj( + file_obj, + self.bucket_name, + object_key, + ExtraArgs=extra_args + ) + + public_url = self.get_public_url(object_key) + s3_url = self.get_s3_url(object_key) + + logger.info(f"Successfully uploaded file to S3: {object_key}") + + return UploadResult( + upload_url='', + object_key=object_key, + public_url=public_url, + object_url=s3_url, + success=True + ) + + except ClientError as e: + error_msg = f"Failed to upload file to S3: {str(e)}" + logger.error(error_msg) + return UploadResult( + upload_url='', + object_key='', + public_url='', + object_url='', + success=False, + error=error_msg + ) + + def delete_file(self, object_key: str) -> bool: + """ + Delete a file from S3 + + Args: + object_key: S3 object key to delete + + Returns: + True if deletion was successful + """ + try: + self.client.delete_object( + Bucket=self.bucket_name, + Key=object_key + ) + logger.info(f"Successfully deleted file from S3: {object_key}") + return True + + except ClientError as e: + logger.error(f"Failed to delete file from S3: {str(e)}") + return False + + def get_public_url(self, object_key: str) -> str: + """ + Get the public URL for an S3 object + + Args: + object_key: S3 object key + + Returns: + Public URL string + """ + return f"https://{self.bucket_name}/{object_key}" + + def get_s3_url(self, object_key: str) -> str: + """ + Get the s3 URL for an S3 object + + Args: + object_key: S3 object key + + Returns: + s3 URL string + """ + return f"s3://{self.bucket_name}/{object_key}" + + def file_exists(self, object_key: str) -> bool: + """ + Check if a file exists in S3 + + Args: + object_key: S3 object key + + Returns: + True if file exists + """ + try: + self.client.head_object( + Bucket=self.bucket_name, + Key=object_key + ) + return True + except ClientError: + return False + + def get_file_from_store(self, object_url: str) -> any: + """ + Get a file from S3 + + Args: + object_url: S3 object URL + + Returns: + File object + """ + bucket_name = os.environ.get('S3_BUCKET_NAME') + parsed = urlparse(object_url) + object_key = parsed.path.lstrip("/") + return self.client.get_object(Bucket=bucket_name, Key=object_key).get('Body').read() \ No newline at end of file diff --git a/chatbot/services/storage/base_storage_handler.py b/chatbot/services/storage/base_storage_handler.py new file mode 100644 index 0000000..df51436 --- /dev/null +++ b/chatbot/services/storage/base_storage_handler.py @@ -0,0 +1,140 @@ +""" +Base storage handler interface for cloud storage operations +""" +from abc import ABC, abstractmethod +from typing import Dict, Optional, BinaryIO +from dataclasses import dataclass +import requests + + +@dataclass +class UploadConfig: + """Configuration for file upload operations""" + file_name: str + file_type: str + folder_structure: Optional[str] = None + entity_id: Optional[str] = None + acl: str | None = None + expires_in: int = 3600 + metadata: Optional[Dict[str, str]] = None + + +@dataclass +class UploadResult: + """Result of an upload operation""" + upload_url: str + object_key: str + public_url: str + object_url: str + success: bool = True + error: Optional[str] = None + + +class BaseStorageHandler(ABC): + """ + Abstract base class for storage handlers. + Defines the interface that all storage providers must implement. + """ + + def __init__(self, config: Dict): + """ + Initialize storage handler with configuration + + Args: + config: Dictionary containing provider-specific configuration + """ + self.config = config + + @abstractmethod + def generate_presigned_url(self, upload_config: UploadConfig) -> UploadResult: + """ + Generate a presigned URL for uploading a file + + Args: + upload_config: Configuration for the upload operation + + Returns: + UploadResult containing upload URL and object details + """ + pass + + @abstractmethod + def upload_file(self, file_obj: BinaryIO, upload_config: UploadConfig) -> UploadResult: + """ + Directly upload a file to storage + + Args: + file_obj: File object to upload + upload_config: Configuration for the upload operation + + Returns: + UploadResult containing upload details + """ + pass + + @abstractmethod + def delete_file(self, object_key: str) -> bool: + """ + Delete a file from storage + + Args: + object_key: Key/path of the object to delete + + Returns: + True if deletion was successful, False otherwise + """ + pass + + @abstractmethod + def get_public_url(self, object_key: str) -> str: + """ + Get the public URL for accessing a file + + Args: + object_key: Key/path of the object + + Returns: + Public URL string + """ + pass + + @abstractmethod + def file_exists(self, object_key: str) -> bool: + """ + Check if a file exists in storage + + Args: + object_key: Key/path of the object + + Returns: + True if file exists, False otherwise + """ + pass + + def _generate_object_key(self, upload_config: UploadConfig) -> str: + """ + Generate object key for the file. + + Args: + upload_config: Upload configuration + + Returns: + Generated object key with format: folder/entity_id/filename + """ + folder = upload_config.folder_structure or '' + entity_part = f"{upload_config.entity_id}/" if upload_config.entity_id else '' + return f"{folder}{entity_part}{upload_config.file_name}" + + def get_file_from_store(self, object_url: str) -> any: + """ + Get a file from storage + + Args: + object_url: URL of the object to get + + Returns: + File object + """ + response = requests.get(object_url) + response.raise_for_status() + return response.content diff --git a/chatbot/services/storage/local_storage_handler.py b/chatbot/services/storage/local_storage_handler.py new file mode 100644 index 0000000..b3acc2d --- /dev/null +++ b/chatbot/services/storage/local_storage_handler.py @@ -0,0 +1,178 @@ +""" +Local file system storage handler implementation +""" +import os +import shutil +import logging +from pathlib import Path +from typing import BinaryIO +from django.conf import settings + +from .base_storage_handler import BaseStorageHandler, UploadConfig, UploadResult + +logger = logging.getLogger('django') + + +class LocalStorageHandler(BaseStorageHandler): + """ + Local file system implementation of the storage handler + Handles file uploads, deletions, and URL generation for local storage + """ + + def __init__(self, config: dict): + """ + Initialize local storage handler + + Args: + config: Dictionary containing local storage configuration + Expected keys: location, base_url + """ + super().__init__(config) + self.location = config.get('location', os.path.join(settings.BASE_DIR, 'media')) + self.base_url = config.get('base_url', '/media/') + self.server_url = config.get('server_url') or os.getenv('BASE_URL', 'http://localhost:8000') + + # Ensure storage directory exists + Path(self.location).mkdir(parents=True, exist_ok=True) + + def generate_presigned_url(self, upload_config: UploadConfig) -> UploadResult: + """ + For local storage, return our server's upload endpoint URL. + This maintains the same flow as AWS where client makes a second request. + + Args: + upload_config: Configuration for the upload operation + + Returns: + UploadResult with our server's upload URL (mimics presigned URL behavior) + """ + try: + object_key = self._generate_object_key(upload_config) + + # For local storage, the "presigned URL" is our server's upload endpoint + # The client will PUT the file to this URL + upload_url = f"{self.server_url}/api/storage/upload-local/{object_key}" + + public_url = self.get_public_url(object_key) + + logger.info(f"Generated local upload URL for: {object_key}") + + return UploadResult( + upload_url=upload_url, + object_key=object_key, + public_url=public_url, + object_url=public_url, + success=True + ) + + except Exception as e: + error_msg = f"Failed to generate local upload URL: {str(e)}" + logger.error(error_msg) + return UploadResult( + upload_url='', + object_key='', + public_url='', + object_url='', + success=False, + error=error_msg + ) + + def upload_file(self, file_obj: BinaryIO, upload_config: UploadConfig) -> UploadResult: + """ + Save a file to local storage + + Args: + file_obj: File object to save + upload_config: Configuration for the upload + + Returns: + UploadResult with file details + """ + try: + object_key = self._generate_object_key(upload_config) + file_path = os.path.join(self.location, object_key) + + # Ensure directory exists + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # Write file to disk + with open(file_path, 'wb') as destination: + for chunk in file_obj.chunks() if hasattr(file_obj, 'chunks') else [file_obj.read()]: + destination.write(chunk) + + public_url = self.get_public_url(object_key) + + logger.info(f"Successfully saved file locally: {object_key}") + + return UploadResult( + upload_url=file_path, + object_key=object_key, + public_url=public_url, + object_url=public_url, + success=True + ) + + except Exception as e: + error_msg = f"Failed to save file locally: {str(e)}" + logger.error(error_msg) + return UploadResult( + upload_url='', + object_key='', + public_url='', + object_url='', + success=False, + error=error_msg + ) + + def delete_file(self, object_key: str) -> bool: + """ + Delete a file from local storage + + Args: + object_key: File path to delete + + Returns: + True if deletion was successful + """ + try: + file_path = os.path.join(self.location, object_key) + + if os.path.isfile(file_path): + os.remove(file_path) + logger.info(f"Successfully deleted local file: {object_key}") + return True + else: + logger.warning(f"File not found for deletion: {object_key}") + return False + + except Exception as e: + logger.error(f"Failed to delete local file: {str(e)}") + return False + + def get_public_url(self, object_key: str) -> str: + """ + Get the public URL for a local file + + Args: + object_key: File path + + Returns: + Public URL string (e.g., http://localhost:9000/media/uploads/file.jpg) + """ + # Normalize path separators for URL + url_path = object_key.replace(os.sep, '/') + return f"{self.server_url}{self.base_url.rstrip('/')}/{url_path}" + + def file_exists(self, object_key: str) -> bool: + """ + Check if a file exists in local storage + + Args: + object_key: File path + + Returns: + True if file exists + """ + file_path = os.path.join(self.location, object_key) + return os.path.isfile(file_path) + diff --git a/chatbot/services/storage/storage_factory.py b/chatbot/services/storage/storage_factory.py new file mode 100644 index 0000000..2344782 --- /dev/null +++ b/chatbot/services/storage/storage_factory.py @@ -0,0 +1,103 @@ +""" +Storage Factory - Factory pattern for creating storage handlers based on cloud provider +""" +import os +import logging +from typing import Optional, Dict + +from .base_storage_handler import BaseStorageHandler +from .aws_storage_handler import AWSS3StorageHandler +from .local_storage_handler import LocalStorageHandler + +logger = logging.getLogger('django') + + +class StorageFactory: + """ + Factory class for creating storage handler instances based on cloud provider. + Supports multiple cloud providers through a unified interface. + """ + + # Registry of available storage handlers + _handlers = { + 'AWS': AWSS3StorageHandler, + 'LOCAL': LocalStorageHandler, + # Add more providers here in the future: + # 'GCP': GCPStorageHandler, + # 'AZURE': AzureStorageHandler, + } + + @classmethod + def get_storage_handler(cls, provider: Optional[str] = None, config: Optional[Dict] = None) -> BaseStorageHandler: + """ + Get a storage handler instance for the specified provider. + + Args: + provider: Cloud provider name (aws, gcp, azure, etc.). + If None, reads from STORAGE_CLOUD_PROVIDER environment variable. + config: Optional configuration dictionary to pass to the handler. + If None, handler will use default configuration from environment. + + Returns: + Instance of the appropriate storage handler + + Raises: + ValueError: If provider is not supported or not configured + """ + # Get provider from parameter or environment + if provider is None: + provider = os.getenv('STORAGE_CLOUD_PROVIDER', '').upper() + else: + provider = provider.upper() + + if not provider: + raise ValueError( + "Cloud provider not specified. Set STORAGE_CLOUD_PROVIDER environment variable " + "or pass provider parameter. Supported providers: " + ", ".join(cls._handlers.keys()) + ) + + # Get handler class from registry + handler_class = cls._handlers.get(provider) + + if handler_class is None: + raise ValueError( + f"Unsupported cloud provider: '{provider}'. " + f"Supported providers: {', '.join(cls._handlers.keys())}" + ) + + # Initialize and return handler + config = config or {} + + logger.info(f"Initializing storage handler for provider: {provider}") + + try: + return handler_class(config) + except Exception as e: + logger.error(f"Failed to initialize storage handler for {provider}: {str(e)}") + raise + + @classmethod + def register_handler(cls, provider: str, handler_class: type): + """ + Register a new storage handler for a provider. + Useful for adding custom storage handlers at runtime. + + Args: + provider: Provider name (e.g., 'custom', 'local') + handler_class: Handler class that extends BaseStorageHandler + """ + if not issubclass(handler_class, BaseStorageHandler): + raise ValueError(f"Handler class must extend BaseStorageHandler") + + cls._handlers[provider.upper()] = handler_class + logger.info(f"Registered storage handler for provider: {provider}") + + @classmethod + def get_supported_providers(cls) -> list: + """ + Get list of supported cloud providers. + + Returns: + List of provider names + """ + return list(cls._handlers.keys()) diff --git a/chatbot/services/strategies/__init__.py b/chatbot/services/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/services/strategies/base_strategy.py b/chatbot/services/strategies/base_strategy.py new file mode 100644 index 0000000..77aafd7 --- /dev/null +++ b/chatbot/services/strategies/base_strategy.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from chatbot.models.company_models import CompanyStateMachine + + +class BotStrategy(ABC): + """Abstract base class for different bot strategies""" + + def __init__(self, route=None, extra_params=None): + self.route = route or self.get_default_route() + self.extra_params = extra_params if extra_params else {} + from ..response_handlers.handler_factory import ResponseHandlerFactory + self.response_handler = ResponseHandlerFactory.create_handler(handler_type=self.get_handler_type()) + + @abstractmethod + def get_default_route(self): + """Get the default route for this strategy""" + pass + + def get_route(self): + return self.route + + @abstractmethod + def get_handler_type(self): + """Get the handler type for this strategy""" + pass + + @abstractmethod + def process_session(self, session_data, **kwargs): + pass + + @abstractmethod + def get_response(self, **kwargs): + pass diff --git a/chatbot/services/strategies/common_strategy.py b/chatbot/services/strategies/common_strategy.py new file mode 100644 index 0000000..227bef0 --- /dev/null +++ b/chatbot/services/strategies/common_strategy.py @@ -0,0 +1,29 @@ +from chatbot.services.strategies.base_strategy import BotStrategy + + +class CommonBotStrategy(BotStrategy): + """Common Strategy for bot functionality""" + + def get_default_route(self): + return '' + + def get_handler_type(self): + return 'common' + + def process_session(self, session_data, **kwargs): + """Handle common session processing""" + chat_session = session_data['chat_session'] + company_bot = session_data['company_bot'] + + try: + from chatbot.models.company_models import CompanyStateMachine + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + return {'state_machine': state_machine} + except Exception as e: + return {'error': f"State machine error: {e}"} + + def get_response(self, **kwargs): + """Get guided guest bot response using handler""" + return self.response_handler.handle_response(**kwargs) diff --git a/chatbot/services/strategies/common_strategy_new.py b/chatbot/services/strategies/common_strategy_new.py new file mode 100644 index 0000000..cc4a73e --- /dev/null +++ b/chatbot/services/strategies/common_strategy_new.py @@ -0,0 +1,29 @@ +from chatbot.services.strategies.base_strategy import BotStrategy + + +class CommonBotStrategyNew(BotStrategy): + """Common Strategy for bot functionality""" + + def get_default_route(self): + return '' + + def get_handler_type(self): + return 'common_new' + + def process_session(self, session_data, **kwargs): + """Handle common session processing""" + chat_session = session_data['chat_session'] + company_bot = session_data['company_bot'] + + try: + from chatbot.models.company_models import CompanyStateMachine + state_machine = CompanyStateMachine.objects.filter( + company_bot=company_bot, step=chat_session.current_step + ).first() + return {'state_machine': state_machine} + except Exception as e: + return {'error': f"State machine error: {e}"} + + def get_response(self, **kwargs): + """Get guided guest bot response using handler""" + return self.response_handler.handle_response(**kwargs) diff --git a/chatbot/services/strategies/guest_discussion.py b/chatbot/services/strategies/guest_discussion.py new file mode 100644 index 0000000..bd50920 --- /dev/null +++ b/chatbot/services/strategies/guest_discussion.py @@ -0,0 +1,29 @@ +from chatbot.services.strategies.base_strategy import BotStrategy + + +class GuestDiscussionBotStrategy(BotStrategy): + """Strategy for guest discussion (chaupal) bot functionality""" + + def get_default_route(self): + return '/shikshalokam_chaupal' + + def get_handler_type(self): + return 'guest_discussion' + + def process_session(self, session_data, **kwargs): + """Handle guest discussion specific session processing""" + chat_session = session_data['chat_session'] + company_bot = session_data['company_bot'] + + try: + from chatbot.models.company_models import CompanyStateMachine + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + return {'state_machine': state_machine} + except Exception as e: + return {'error': f"State machine error: {e}"} + + def get_response(self, **kwargs): + """Get guided guest bot response using handler""" + return self.response_handler.handle_response(**kwargs) diff --git a/chatbot/services/strategies/guided.py b/chatbot/services/strategies/guided.py new file mode 100644 index 0000000..31820ee --- /dev/null +++ b/chatbot/services/strategies/guided.py @@ -0,0 +1,36 @@ +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.strategies.base_strategy import BotStrategy + + +class GuidedGuestBotStrategy(BotStrategy): + """Strategy for guided guest bot functionality""" + + def get_default_route(self): + return '/guided_guest' + + def get_handler_type(self): + return 'guided_guest' + + def process_session(self, session_data, **kwargs): + """Handle guided guest specific session processing""" + chat_session = session_data['chat_session'] + company_chats = session_data['company_chats'] + profile = session_data['profile'] + company_bot = session_data['company_bot'] + + # Increment step for new profiles + # if company_chats and len(company_chats) < 2 and profile and profile.first_name: + # chat_session.current_step += 1 + # chat_session.save() + + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, step=chat_session.current_step + ) + return {'state_machine': state_machine} + except Exception as e: + return {'error': f"State machine error: {e}"} + + def get_response(self, **kwargs): + """Get guided guest bot response using handler""" + return self.response_handler.handle_response(**kwargs) diff --git a/chatbot/services/strategies/oneshot.py b/chatbot/services/strategies/oneshot.py new file mode 100644 index 0000000..a0cce93 --- /dev/null +++ b/chatbot/services/strategies/oneshot.py @@ -0,0 +1,83 @@ +from chatbot.models.company_models import CompanyStateMachine +from chatbot.services.strategies.base_strategy import BotStrategy + + +class OneShotBotStrategy(BotStrategy): + """Strategy for one-shot bot functionality""" + + def get_default_route(self): + return '/oneshot_guest' + + def get_handler_type(self): + return 'oneshot' + + def process_session(self, session_data, **kwargs): + """Handle one-shot specific session processing""" + from chatbot.utils.one_shot_utils import get_remaining_strands + + chat_session = session_data['chat_session'] + company_chats = session_data['company_chats'] + profile = session_data['profile'] + company_bot = session_data['company_bot'] + + intro_mssg = kwargs.get('intro_mssg') + other_info = kwargs.get('other_info') + messages = kwargs.get('messages', []) + + if not chat_session.session_context: + chat_session.session_context = {} + + remaining_stages = chat_session.session_context.get('remaining_stages') + + # Check if we need to get remaining stages + if not remaining_stages and self._should_get_remaining_stages(intro_mssg, messages): + + remaining_stages_response = get_remaining_strands( + messages=messages, + company_chats=company_chats, + oneshot_bot=company_bot, + profile=profile, + intro=intro_mssg, + other_info=other_info, + extra_params=self.extra_params + ) + + if remaining_stages_response and remaining_stages_response.get('error'): + return {'error': remaining_stages_response.get('error')} + + remaining_stages = remaining_stages_response.get('remaining_stages', []) + + if remaining_stages and isinstance(remaining_stages, str): + remaining_stages = [] + + # Filter unwanted stages for existing profiles + if profile and profile.first_name and remaining_stages: + unwanted_stages = {'PERSONAL_INFO', 'LOCATION_INFO'} + remaining_stages = [stage for stage in remaining_stages if stage not in unwanted_stages] + + remaining_stages.append('APPRECIATION') + chat_session.session_context['remaining_stages'] = remaining_stages + chat_session.save() + + # Get current stage and update session + current_stage_name = remaining_stages[0] + try: + state_machine = CompanyStateMachine.objects.get( + company_bot=company_bot, name=current_stage_name + ) + chat_session.current_step = state_machine.step + chat_session.save() + return {'state_machine': state_machine, 'remaining_stages': remaining_stages} + except Exception as e: + return {'error': f"State machine error: {e}"} + + def _should_get_remaining_stages(self, intro_mssg, messages): + """Check if remaining stages should be retrieved""" + return ( + (intro_mssg is None and len(messages) < 2) or + (intro_mssg is not None and len(messages) <= 3) + ) + + def get_response(self, **kwargs): + """Get one-shot bot response using handler""" + return self.response_handler.handle_response(**kwargs) diff --git a/chatbot/templates/admin/batch_upload/batch_upload.html b/chatbot/templates/admin/batch_upload/batch_upload.html new file mode 100644 index 0000000..f2008ea --- /dev/null +++ b/chatbot/templates/admin/batch_upload/batch_upload.html @@ -0,0 +1,96 @@ +{% extends "admin/base_site.html" %} +{% load static i18n admin_urls %} + +{% block title %}Batch Upload{% endblock %} + +{% block extrahead %} +{{ block.super }} + + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
      +

      Batch Upload

      + + {% include 'admin/batch_upload/includes/step_indicator.html' %} + {% include 'admin/batch_upload/includes/status_messages.html' %} + + +
      + {% include 'admin/batch_upload/steps/step1_upload.html' %} +
      + + +
      + {% include 'admin/batch_upload/steps/step2_review.html' %} +
      + + +
      + {% include 'admin/batch_upload/steps/step3_save.html' %} +
      + + {% include 'admin/batch_upload/includes/modals.html' %} +
      + + +{{ file_types|json_script:"file-types-data" }} +{{ existing_manual_tags|json_script:"existing-manual-tags" }} +{{ master_document_types|json_script:"master-document-types" }} + + + + + + +{% endblock %} \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/components/file_item.html b/chatbot/templates/admin/batch_upload/components/file_item.html new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/templates/admin/batch_upload/components/media_item.html b/chatbot/templates/admin/batch_upload/components/media_item.html new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/templates/admin/batch_upload/components/pagination_controls.html b/chatbot/templates/admin/batch_upload/components/pagination_controls.html new file mode 100644 index 0000000..a9de782 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/components/pagination_controls.html @@ -0,0 +1,18 @@ +
      +
      + Showing 1 + of 0 files +
      +
      + + + + + +
      +
      + Go to page: + + +
      +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/components/subdocument_item.html b/chatbot/templates/admin/batch_upload/components/subdocument_item.html new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/templates/admin/batch_upload/css/batch_upload.css b/chatbot/templates/admin/batch_upload/css/batch_upload.css new file mode 100644 index 0000000..1fe98ca --- /dev/null +++ b/chatbot/templates/admin/batch_upload/css/batch_upload.css @@ -0,0 +1,1884 @@ +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=Urbanist:ital,wght@0,100..900;1,100..900&display=swap'); + +:root { + --primary-font-family: "Manrope", sans-serif; + --secondary-font-family: "Urbanist", sans-serif; +} + +body { + font-family: var(--primary-font-family); +} + +.h1 { + font-size: 2rem !important; +} + +.h2 { + font-size: 1.5rem !important; +} + +.p { + font-size: 1.2rem !important; +} + +* { + box-sizing: border-box; +} + +.batch-upload-container { + max-width: 100%; + width: 100%; + margin: 0 auto; + background-color: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.step-indicator { + display: flex; + justify-content: center; + margin-bottom: 40px; + gap: 20px; +} + +.step { + display: flex; + align-items: center; + gap: 10px; +} + +.step-number { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #e0e0e0; + color: #666; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + transition: all 0.3s ease; +} + +.step.active .step-number { + background-color: #417690; + color: white; +} + +.step.completed .step-number { + background-color: #5cb85c; + color: white; +} + +.step-label { + font-size: 14px; + color: #666; +} + +.step.active .step-label { + color: #333; + font-weight: 500; +} + +.content-section { + display: none; +} + +.content-section.active { + display: block; +} + +/* Upload Section */ +.upload-area { + border: 2px dashed #ccc; + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background-color: #fafafa; +} + +.upload-area:hover { + border-color: #417690; + background-color: #f0f9ff; +} + +.upload-area.dragover { + border-color: #417690; + background-color: #e8f5e9; +} + +input[type="file"] { + display: none; +} + +.file-list { + margin-top: 20px; + max-height: 400px; + overflow-y: auto; +} + +.file-sections { + margin-top: 20px; +} + +.file-section { + margin-bottom: 30px; + border-radius: 8px; + border: 1px solid #e0e0e0; + padding: 20px; +} + +.file-section.successful-section { + background-color: #f9f9f9; +} + +.file-section.failed-section { + background-color: #fff5f5; + border-color: #ffcdd2; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #e0e0e0; +} + +.section-title { + font-size: 18px; + font-weight: 600; + color: #333; +} + +.failed-section .section-title { + color: #d32f2f; +} + +.section-count { + font-size: 14px; + color: #666; + font-weight: normal; +} + +.retry-all-btn { + background-color: #ff9800; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; +} + +.retry-all-btn:hover { + background-color: #f57c00; +} + +.file-item { + background-color: #f5f5f5; + margin-bottom: 8px; + border-radius: 6px; + border-left: 4px solid #ddd; + overflow: hidden; +} + +.file-item.success { + border-left-color: #5cb85c; + background-color: #f0f9f0; +} + +.file-item.error { + border-left-color: #f44336; + background-color: #fff5f5; +} + +.file-item.skipped { + border-left-color: #ff9800; + background-color: #fff8e1; + opacity: 0.7; +} + +.file-item.processing { + border-left-color: #2196f3; + background-color: #e3f2fd; +} + +.file-item-main { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; +} + +.file-item-content { + display: flex; + align-items: center; + gap: 15px; + flex: 1; +} + +.file-status-icon { + font-size: 20px; + width: 24px; + text-align: center; +} + +.file-info { + flex: 1; +} + +.file-name { + font-weight: 500; + color: #333; +} + +.file-size { + color: #666; + font-size: 14px; +} + +.file-status-text { + color: #666; + font-size: 12px; + margin-top: 4px; +} + +.file-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.file-item .remove-btn { + color: #f44336; + cursor: pointer; + font-weight: bold; + padding: 4px 8px; + border-radius: 4px; + background: transparent; + border: 1px solid #f44336; + font-size: 12px; +} + +.file-item .remove-btn:hover { + background-color: #f44336; + color: white; +} + +.retry-btn { + background-color: #ff9800; + color: white; + border: none; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; +} + +.retry-btn:hover { + background-color: #f57c00; +} + +.skip-btn { + background-color: #9e9e9e; + color: white; + border: none; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; +} + +.skip-btn:hover { + background-color: #757575; +} + +.error-toggle { + background-color: #f44336; + color: white; + border: none; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; +} + +.error-toggle:hover { + background-color: #d32f2f; +} + +/* Error Details Section */ +.file-error-details { + display: none; + padding: 15px; + background-color: #fff; + border-top: 1px solid #ffcdd2; + margin: 0; +} + +.file-error-details.show { + display: block; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + } + to { + opacity: 1; + max-height: 200px; + padding-top: 15px; + padding-bottom: 15px; + } +} + +.error-details-content { + background-color: #fff5f5; + padding: 12px; + border-radius: 4px; + border: 1px solid #ffcdd2; +} + +.error-title { + font-weight: 600; + color: #c62828; + margin-bottom: 8px; + font-size: 14px; +} + +.error-message { + color: #f44336; + font-size: 13px; + line-height: 1.4; + word-break: break-word; +} + +.error-timestamp { + color: #999; + font-size: 11px; + margin-top: 8px; + font-style: italic; +} + +/* Company Bot Selection - Hidden */ +.company-bot-selection { + display: none; +} + +/* Upload Progress Section */ +.extraction-progress { + margin: 20px 0; + padding: 20px; + background-color: #f8f9fa; + border-radius: 8px; + display: none; +} + +.extraction-progress.show { + display: block; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.progress-title { + font-size: 16px; + font-weight: 600; + color: #333; +} + +.progress-stats { + display: flex; + gap: 20px; + font-size: 14px; +} + +.progress-stat { + display: flex; + align-items: center; + gap: 5px; +} + +.progress-stat.success { + color: #5cb85c; +} + +.progress-stat.error { + color: #f44336; +} + +.progress-stat.processing { + color: #2196f3; +} + +.progress-stat.skipped { + color: #ff9800; +} + +.progress-bar-container { + margin: 15px 0; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(to right, #5cb85c, #81c784); + transition: width 0.3s ease; + border-radius: 4px; +} + +.progress-text { + font-size: 12px; + color: #666; + margin-top: 5px; +} + +/* Review Section */ +.data-grid { + margin-top: 20px; + overflow-x: auto; +} + +.media-item { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + background-color: #fafafa; + display: none; +} + +.media-item.active { + display: block; +} + +.media-item.error { + border-color: #f44336; + background-color: #fff5f5; +} + +.media-item.skipped { + border-color: #ff9800; + background-color: #fff8e1; + opacity: 0.7; +} + +.media-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.media-item-title { + font-size: 18px; + font-weight: 600; + color: #333; +} + +.media-item-status { + display: flex; + align-items: center; + gap: 10px; +} + +.status-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; +} + +.status-badge.success { + background-color: #d4edda; + color: #155724; +} + +.status-badge.error { + background-color: #f8d7da; + color: #721c24; +} + +.status-badge.skipped { + background-color: #fff3cd; + color: #856404; +} + +.media-item-actions { + display: flex; + gap: 10px; +} + +.btn-save { + background-color: #5cb85c; + color: white; +} + +.btn-save:hover { + background-color: #4cae4c; +} + +.btn-save.saved { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.field-group { + margin-bottom: 15px; +} + +.field-group label { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: #555; +} + +.field-group input, +.field-group select, +.field-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.field-group textarea { + resize: vertical; + min-height: 80px; +} + +/* Tags Section */ +.tags-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 15px; +} + +.tags-subsection { + border: 1px solid #ddd; + border-radius: 4px; + padding: 15px; +} + +.tags-subsection h4 { + margin: 0 0 10px 0; + color: #333; + font-size: 16px; +} + +.tags-input { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + min-height: 40px; + background-color: white; +} + +.tag { + padding: 4px 12px; + border-radius: 16px; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; +} + +.tag.manual { + background-color: #e3f2fd; + color: #1976d2; +} + +.tag.auto { + background-color: #f3e5f5; + color: #7b1fa2; +} + +.tag .remove-tag { + cursor: pointer; + font-weight: bold; +} + +.key-values { + margin-top: 15px; +} + +.key-value-pair { + display: grid; + grid-template-columns: 200px 1fr auto; + gap: 10px; + margin-bottom: 10px; + align-items: start; +} + +.key-value-pair input[type="text"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-family: inherit; +} + +.key-value-pair input:first-child { + font-weight: 600; + background-color: #f8f9fa; +} + +.key-value-textarea { + width: 100%; + min-height: 40px; + max-height: 200px; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-family: inherit; + resize: vertical; + overflow-y: auto; +} + +.kv-key-input { + font-weight: 600; + background-color: #f8f9fa; + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.structured-content-kv { + display: grid; + grid-template-columns: 250px 1fr auto; + gap: 15px; + margin-bottom: 20px; + align-items: start; +} + +.structured-content-kv .key-value-textarea { + width: 100%; + min-height: 60px; + max-height: 300px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-family: inherit; + resize: vertical; + overflow-y: auto; + line-height: 1.6; + white-space: pre-wrap; +} + +.structured-content-kv .kv-key { + font-weight: 600; + color: #333; + margin-bottom: 8px; + font-size: 15px; +} + +.structured-content-kv .kv-value { + background-color: white; + padding: 12px; + border-radius: 4px; + border: 1px solid #ddd; + white-space: pre-wrap; /* Preserve formatting */ + line-height: 1.6; + max-height: 300px; + overflow-y: auto; +} + +.add-kv-btn { + background-color: #5cb85c; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.remove-kv-btn { + background-color: #f44336; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +/* Subdocuments Section */ +.subdocuments-section { + margin-top: 20px; + border-top: 2px solid #e0e0e0; + padding-top: 20px; +} + +.subdocuments-header { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; +} + +.subdocument-count { + font-size: 14px; + color: #666; + font-weight: normal; +} + +.subdocument-item { + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 12px; + background-color: #fff; + transition: all 0.3s ease; +} + +.subdocument-item:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.subdocument-header { + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + background-color: #f8f9fa; + border-radius: 6px 6px 0 0; + transition: background-color 0.3s ease; +} + +.subdocument-header:hover { + background-color: #e9ecef; +} + +.subdocument-header.expanded { + background-color: #e3f2fd; + border-bottom: 1px solid #ddd; +} + +.subdocument-title { + font-weight: 500; + color: #333; + flex: 1; +} + +.subdocument-controls { + display: flex; + gap: 10px; + align-items: center; +} + +.expand-icon { + width: 20px; + height: 20px; + transition: transform 0.3s ease; + color: #666; +} + +.subdocument-header.expanded .expand-icon { + transform: rotate(180deg); +} + +.subdocument-content { + display: none; + padding: 20px; + background-color: #fafafa; + border-radius: 0 0 6px 6px; +} + +.subdocument-content.show { + display: block; + animation: slideDown 0.3s ease-out; +} + +.remove-subdoc-btn { + background-color: #f44336; + color: white; + border: none; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} + +.remove-subdoc-btn:hover { + background-color: #d32f2f; +} + +/* Images Section */ +.images-section { + margin-top: 20px; + border-top: 2px solid #e0e0e0; + padding-top: 20px; +} + +.images-header { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 15px; +} + +.images-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 15px; +} + +.image-item { + border: 1px solid #ddd; + border-radius: 6px; + padding: 10px; + background-color: #fff; + text-align: center; +} + +.image-preview { + max-width: 100%; + max-height: 200px; + object-fit: contain; + border-radius: 4px; + margin-bottom: 8px; + cursor: pointer; + transition: transform 0.2s ease; +} + +.image-preview:hover { + transform: scale(1.05); +} + +.image-info { + font-size: 12px; + color: #666; + margin-top: 5px; +} + +.image-page { + font-weight: 500; + color: #333; +} + +.remove-image-btn { + background-color: #f44336; + color: white; + border: none; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + margin-top: 8px; +} + +.remove-image-btn:hover { + background-color: #d32f2f; +} + +/* Image Modal */ +.image-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.9); +} + +.image-modal-content { + margin: auto; + display: block; + max-width: 90%; + max-height: 90%; + position: relative; + top: 50%; + transform: translateY(-50%); +} + +.close-modal { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; + cursor: pointer; +} + +.close-modal:hover, +.close-modal:focus { + color: #bbb; + text-decoration: none; + cursor: pointer; +} + +/* Pagination Controls */ +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin: 30px 0; + padding: 20px; + background-color: #f5f5f5; + border-radius: 8px; +} + +.pagination-info { + font-size: 16px; + color: #333; +} + +.pagination-nav { + display: flex; + gap: 10px; + align-items: center; +} + +.page-btn { + padding: 8px 16px; + border: 1px solid #ddd; + background-color: white; + color: #333; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s ease; +} + +.page-btn:hover:not(:disabled) { + background-color: #f0f0f0; +} + +.page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-btn.active { + background-color: #417690; + color: white; + border-color: #417690; +} + +.page-jump { + display: flex; + align-items: center; + gap: 10px; +} + +.page-jump input { + width: 60px; + padding: 6px; + border: 1px solid #ddd; + border-radius: 4px; + text-align: center; +} + +.page-jump button { + padding: 6px 12px; + background-color: #417690; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.page-jump button:hover { + background-color: #205067; +} + +/* Buttons */ +.button-group { + display: flex; + justify-content: space-between; + margin-top: 30px; + gap: 15px; +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-block; +} + +.btn-primary { + background-color: #417690; + color: white; +} + +.btn-primary:hover { + background-color: #205067; +} + +.btn-secondary { + background-color: #f0f0f0; + color: #333; +} + +.btn-secondary:hover { + background-color: #e0e0e0; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Status Messages */ +.status-message { + padding: 12px; + border-radius: 6px; + margin: 20px 0; + display: none; +} + +.status-message.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-message.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.status-message.info { + background-color: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.status-message.warning { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeeba; +} + +/* Loading Spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #417690; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.loading-content { + background-color: white; + padding: 30px; + border-radius: 8px; + text-align: center; +} + +.loading-content .spinner { + width: 40px; + height: 40px; + margin: 0 auto 20px; +} + +/* Summary Section */ +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin: 20px 0; +} + +.summary-card { + background-color: #f5f5f5; + padding: 20px; + border-radius: 8px; + text-align: center; +} + +.summary-card h3 { + color: #666; + font-size: 14px; + margin-bottom: 10px; +} + +.summary-card .value { + font-size: 28px; + font-weight: bold; + color: #333; +} + +.results-list { + margin-top: 30px; +} + +.result-item { + display: flex; + align-items: center; + padding: 15px; + background-color: #f9f9f9; + margin-bottom: 10px; + border-radius: 6px; +} + +.result-item.success { + border-left: 4px solid #5cb85c; +} + +.result-item.error { + border-left: 4px solid #f44336; +} + +.result-icon { + margin-right: 15px; + font-size: 28px; /* Increased from 24px */ + display: flex; + align-items: center; +} + +/* Specific styling for success/error icons */ +.save-result-item.success .result-icon { + color: #5cb85c; +} + +.save-result-item.error .result-icon { + color: #f44336; +} + +.result-details h4 { + margin-bottom: 5px; +} + +.result-details p { + color: #666; + font-size: 14px; +} + +/* Auto-save Status */ +.auto-save-status { + position: fixed; + top: 20px; + right: 20px; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + display: none; + z-index: 1000; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.auto-save-status.saving { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeeba; + display: block; +} + +.auto-save-status.saved { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + display: block; +} + +.auto-save-status.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + display: block; +} + +/* Breadcrumbs override */ +.breadcrumbs { + margin-bottom: 20px; +} + +/* Save results in step 3 */ +.save-result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + margin-bottom: 10px; + border-radius: 6px; + border-left: 4px solid #ddd; +} + +.save-result-item.success { + border-left-color: #5cb85c; + background-color: #f0f9f0; +} + +.save-result-item.error { + border-left-color: #f44336; + background-color: #fff5f5; +} + +.save-result-content { + flex: 1; +} + +.save-result-actions { + display: flex; + gap: 8px; +} + +/* Disabled state for items */ +.media-item.disabled { + pointer-events: none; + opacity: 0.5; +} + +.file-item.disabled { + pointer-events: none; + opacity: 0.5; +} + +/* Hide specific fields */ +.field-group.hidden { + display: none; +} + +#retryAllBtn { + background-color: #ff9800; + color: white; + padding: 12px 30px; + font-size: 16px; + font-weight: 600; +} + +#retryAllBtn:hover { + background-color: #f57c00; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #999; + font-size: 14px; +} +.btn.retry-btn[onclick*="saveAnywayResult"] { + display: none !important; +} + +.subdoc-hierarchy { + margin-left: 30px; + border-left: 2px solid #e0e0e0; + padding-left: 15px; + margin-top: 10px; +} + +.subdoc-result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + margin-bottom: 8px; + border-radius: 4px; + background-color: #f9f9f9; + border-left: 3px solid #ddd; + gap: 15px; /* Add gap for spacing */ +} + +.subdoc-result-item > div:first-child { + flex: 1; + max-width: calc(100% - 100px); /* Leave space for button */ + overflow: hidden; +} + +.subdoc-result-item strong { + display: block; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.subdoc-result-item.success { + border-left-color: #5cb85c; + background-color: #f0f9f0; +} + +.subdoc-result-item.error { + border-left-color: #f44336; + background-color: #fff5f5; +} + +.subdoc-toggle { + cursor: pointer; + margin-right: 10px; + user-select: none; + font-weight: bold; +} + +.subdoc-results { + display: none; +} + +.subdoc-results.expanded { + display: block; +} + +.subdoc-stats { + display: inline-flex; + gap: 15px; + margin-left: 10px; + font-size: 13px; +} + +.subdoc-stat { + display: flex; + align-items: center; + gap: 5px; +} + +.subdoc-stat.success { + color: #5cb85c; +} + +.subdoc-stat.failed { + color: #f44336; +} + +/* Better arrow design */ +.expand-icon { + width: 24px; + height: 24px; + transition: transform 0.3s ease; + color: #666; + display: flex; + align-items: center; + justify-content: center; +} + +.expand-icon svg { + width: 16px; + height: 16px; +} +.subdoc-toggle-arrow { + width: 20px; + height: 20px; + cursor: pointer; + transition: transform 0.3s ease; + color: #666; + margin-right: 10px; +} + +.subdoc-toggle-arrow.expanded { + transform: rotate(90deg); +} +.failed-links-section { + margin-top: 20px; + padding: 15px; + background-color: #fff5f5; + border: 1px solid #ffcdd2; + border-radius: 8px; +} + +.failed-links-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.failed-links-title { + font-size: 16px; + font-weight: 600; + color: #d32f2f; +} + +.failed-link-item { + background-color: white; + padding: 10px; + margin-bottom: 8px; + border-radius: 4px; + border: 1px solid #ffcdd2; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.failed-link-info { + flex: 1; + min-width: 200px; +} + +.failed-link-url { + font-weight: 500; + color: #333; + word-break: break-all; + font-size: 14px; + line-height: 1.4; +} + +.failed-link-error { + color: #d32f2f; + font-size: 13px; + margin-top: 4px; + line-height: 1.3; +} + +.failed-link-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} +@media (max-width: 600px) { + .failed-link-item { + flex-direction: column; + align-items: stretch; + } + + .failed-link-actions { + margin-top: 10px; + justify-content: flex-end; + } + + .failed-link-info { + min-width: unset; + } +} +.info-message { + background-color: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} + +.info-message p { + margin: 0; + line-height: 1.5; +} + +.info-message strong { + font-weight: 600; +} + +.tag-input-wrapper { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-top: 8px; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; +} + +.tag-dropdown { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + cursor: pointer; +} + +.tag-dropdown:focus { + outline: none; + border-color: #417690; +} + +.tag-input-field { + flex: 1; + min-width: 120px; + border: none; + outline: none; + padding: 4px 8px; + font-size: 14px; +} +.custom-dropdown { + position: relative; + width: 250px; /* Increased width */ + max-width: 100%; +} + +.dropdown-search { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + cursor: text; + box-sizing: border-box; +} + +.dropdown-search:focus { + outline: none; + border-color: #417690; + box-shadow: 0 0 0 2px rgba(65, 118, 144, 0.1); +} + +.dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 200px; /* Limit height */ + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.dropdown-list.show { + display: block; +} + +.dropdown-option { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; + /* Text wrapping */ + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + line-height: 1.4; + max-width: 100%; +} + +.dropdown-option:hover { + background-color: #f5f5f5; +} + +.dropdown-option:last-child { + border-bottom: none; +} + +.dropdown-option.highlighted { + background-color: #e3f2fd; +} + +.dropdown-option.no-results { + color: #999; + font-style: italic; + cursor: default; +} + +.dropdown-option.no-results:hover { + background-color: white; +} + +.kv-value-container { + width: 100%; + padding: 0; + border: none; + background: white; + display: flex; + align-items: center; + position: relative; /* Important for dropdown positioning */ +} + +.kv-value-container .custom-dropdown { + width: 100%; + max-width: 100%; + position: relative; +} + +.kv-value-container .dropdown-search { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + cursor: text; + box-sizing: border-box; + font-family: inherit; +} + +.kv-value-container .dropdown-search:focus { + outline: none; + border-color: #417690; + box-shadow: 0 0 0 2px rgba(65, 118, 144, 0.1); +} + +/* Ensure dropdown list positioning works properly within the container */ +.kv-value-container .dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.kv-value-container .dropdown-list.show { + display: block; +} + +.kv-value-container .dropdown-option { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + line-height: 1.4; + max-width: 100%; +} + +.kv-value-container .dropdown-option:hover { + background-color: #f5f5f5; +} + +.kv-value-container .dropdown-option:last-child { + border-bottom: none; +} + +.kv-value-container .dropdown-option.highlighted { + background-color: #e3f2fd; +} + +.kv-value-container .dropdown-option.no-results { + color: #999; + font-style: italic; + cursor: default; +} + +.kv-value-container .dropdown-option.no-results:hover { + background-color: white; +} +.key-value-textarea.formatted-list { + font-family: inherit; + line-height: 1.6; + white-space: pre-wrap; + background-color: #fafafa; + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px; +} + +.key-value-textarea.long-content { + min-height: 120px; + max-height: 300px; + overflow-y: auto; +} +.key-value-textarea.formatted-list::placeholder { + opacity: 0.6; + font-style: italic; +} + +/* Better visual separation for complex content */ +.structured-content-kv .kv-key-input { + font-weight: 600; + background-color: #f8f9fa; + min-width: 200px; +} +@media (max-width: 768px) { + .structured-content-kv { + grid-template-columns: 1fr; + gap: 10px; + } + + .key-value-textarea.long-content { + min-height: 100px; + } +} +.kv-key-input[readonly], +.key-value-textarea[readonly] { + background-color: #f8f9fa !important; + border-color: #e9ecef !important; + cursor: not-allowed !important; + color: #6c757d !important; +} + +.kv-key-input[readonly]:focus, +.key-value-textarea[readonly]:focus { + box-shadow: none !important; + border-color: #e9ecef !important; +} +/* Editable field indicator */ +.editable-field { + position: relative; +} + +.editable-field::after { + content: "✏️"; + position: absolute; + top: 8px; + right: 8px; + font-size: 14px; + pointer-events: none; + z-index: 2; + background: rgba(255, 255, 255, 0.8); + border-radius: 3px; + padding: 2px; + opacity: 0.7; +} + +.editable-field:focus::after { + opacity: 0.3; +} + +/* Ensure container positioning for absolute placement */ +.field-group { + position: relative; +} + +.key-value-pair { + position: relative; +} + +/* Adjust padding-right for editable textareas to prevent text overlap */ +.editable-field textarea, +.editable-field input[type="text"]:not([readonly]) { + padding-right: 35px !important; +} +.subdocument-controls input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: #417690; + margin: 0; + padding: 0; +} +.subdocument-controls label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + margin: 0; + user-select: none; + font-size: 13px; + color: #555; + transition: all 0.2s ease; +} + +.subdocument-controls label:hover { + color: #333; +} + +.subdocument-controls input[type="checkbox"]:hover { + transform: scale(1.1); +} + +.subdocument-controls input[type="checkbox"]:checked { + background-color: #417690; +} + +/* Private mode indicator styling */ +.subdocument-item[data-display-mode="private"]::before { + content: "🔒 "; + font-size: 14px; +} + +/* Optional: Add visual indicator to the entire subdocument when private */ +.subdocument-item.private-mode { + opacity: 0.8; + border-left-color: #ff9800; +} + +.subdocument-item.private-mode .subdocument-header { + background-color: #fff8e1; +} + +/* Checkbox focus state */ +.subdocument-controls input[type="checkbox"]:focus { + outline: 2px solid #417690; + outline-offset: 2px; + border-radius: 2px; +} + +.subdocument-private-info { + background-color: #fff8e1; + border-left: 4px solid #ff9800; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + color: #856404; + margin-top: 10px; + display: none; +} + +.subdocument-private-info.show { + display: block; +} + +.subdocument-private-info::before { + content: "ℹ️ "; + margin-right: 5px; +} diff --git a/chatbot/templates/admin/batch_upload/includes/modals.html b/chatbot/templates/admin/batch_upload/includes/modals.html new file mode 100644 index 0000000..f3835e8 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/includes/modals.html @@ -0,0 +1,7 @@ + +
      +
      +
      +

      Processing...

      +
      +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/includes/status_messages.html b/chatbot/templates/admin/batch_upload/includes/status_messages.html new file mode 100644 index 0000000..4c1e621 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/includes/status_messages.html @@ -0,0 +1,7 @@ + +
      + All changes saved +
      + + +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/includes/step_indicator.html b/chatbot/templates/admin/batch_upload/includes/step_indicator.html new file mode 100644 index 0000000..8ef1d85 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/includes/step_indicator.html @@ -0,0 +1,15 @@ + +
      +
      +
      1
      + Upload +
      +
      +
      2
      + Review & Edit +
      +
      +
      3
      + Save to Database +
      +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/js/batch_upload.js b/chatbot/templates/admin/batch_upload/js/batch_upload.js new file mode 100644 index 0000000..4f61402 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/js/batch_upload.js @@ -0,0 +1,4860 @@ +// ============================================ +// SECTION 1: INITIALIZATION & CONFIGURATION +// ============================================ + +// CSRF token for Django +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +const existingManualTags = JSON.parse(document.getElementById('existing-manual-tags').textContent); +const masterDocumentTypes = JSON.parse(document.getElementById('master-document-types').textContent); +let selectedOrganization = null; +let userDefaultCompany = null; +let pendingOrgSelection = null; + +const csrftoken = getCookie('csrftoken'); +let expandedSubdocumentPaths = new Set(); +const DEFAULT_BOT_ID = {{ default_bot_id|default:"null" }}; + +// Dynamic file types from Django +const fileTypesData = JSON.parse(document.getElementById('file-types-data').textContent); +const mediaTypesJS = [ + {% for value, label in media_types %} + { value: "{{ value }}", label: "{{ label }}" }, + {% endfor %} +]; + +// Create valid MIME types array and extension regex +const validTypes = fileTypesData.map(ft => ft.mime_type); +const validExtensions = fileTypesData + .map(ft => ft.extension.replace('.', '')) + .filter(ext => ext) + .join('|'); +const extensionRegex = new RegExp(`\\.(${validExtensions})$`, 'i'); + +// Global variables +let uploadedFiles = []; +let extractedData = []; +let currentStep = 1; +let currentPage = 1; +let itemsPerPage = 1; +let totalPages = 1; +let pollingInterval = null; +let isExtracting = false; +let sessionId = null; +let isWaitingForAI = false; +let lastSaveTimer = null; +let expandedSubdocument = null; +let userCompanyName = ''; + +// Media types and priorities from Django +const mediaTypes = {{ media_types|safe }}; +const priorities = {{ priorities|safe }}; +const BOT_PROFILE_ID = 1; + +{% if user.is_authenticated %} + {% with user_profile=user.profile %} + {% if user_profile and user_profile.company %} + userCompanyName = "{{ user_profile.company.name|escapejs }}"; + {% endif %} + {% endwith %} +{% endif %} + +{% if user_company %} +userDefaultCompany = { + slug: "{{ user_company.slug|escapejs }}", + name: "{{ user_company.name|escapejs }}" +}; +{% endif %} + +// File status constants +const FILE_STATUS = { + PENDING: 'pending', + PROCESSING: 'processing', + SUCCESS: 'success', + ERROR: 'error', + SKIPPED: 'skipped' +}; + +// ============================================ +// SECTION 3: STEP 1 - UPLOAD FUNCTIONS +// ============================================ +// Organization selection +function initializeOrganizationSelect() { + const orgSelect = document.getElementById('organizationSelect'); + + // Set default selection + if (userDefaultCompany) { + orgSelect.value = userDefaultCompany.slug; + selectedOrganization = userDefaultCompany; + } + + // Add change event listener + orgSelect.addEventListener('change', function() { + const selectedSlug = this.value; + const selectedName = this.options[this.selectedIndex].getAttribute('data-name'); + + if (!selectedSlug) { + selectedOrganization = null; + return; + } + + const newSelection = { + slug: selectedSlug, + name: selectedName + }; + + // Check if different from user's default company + if (userDefaultCompany && selectedSlug !== userDefaultCompany.slug) { + // Show confirmation modal + pendingOrgSelection = newSelection; + document.getElementById('selectedOrgName').textContent = selectedName; + document.getElementById('orgConfirmModal').style.display = 'block'; + } else { + selectedOrganization = newSelection; + } + }); +} + +function confirmOrgSelection(confirmed) { + const modal = document.getElementById('orgConfirmModal'); + const orgSelect = document.getElementById('organizationSelect'); + + if (confirmed && pendingOrgSelection) { + selectedOrganization = pendingOrgSelection; + } else { + // Revert to default + if (userDefaultCompany) { + orgSelect.value = userDefaultCompany.slug; + selectedOrganization = userDefaultCompany; + } else { + orgSelect.value = ''; + selectedOrganization = null; + } + } + + modal.style.display = 'none'; + pendingOrgSelection = null; +} + +function validateOrganizationSelection() { + if (!selectedOrganization) { + showStatus('Please select an organization before uploading files', 'error'); + return false; + } + return true; +} + +// Initialize with default bot selected +window.addEventListener('DOMContentLoaded', function() { + initializeOrganizationSelect(); + resetToDefaultBot(); +}); + +function resetToDefaultBot() { + const selectElement = document.getElementById('companyBotSelect'); + if (selectElement) { + if (DEFAULT_BOT_ID) { + selectElement.value = DEFAULT_BOT_ID; + } else { + // If no default, select the first bot + const firstOption = selectElement.querySelector('option[value]:not([value=""])'); + if (firstOption) { + selectElement.value = firstOption.value; + } + } + } +} + + +// ============================================ +// SECTION 2: SHARED UTILITIES +// ============================================ +// Status & Loading functions + +function showStatus(message, type = 'info') { + const statusEl = document.getElementById('statusMessage'); + statusEl.textContent = message; + statusEl.className = `status-message ${type}`; + statusEl.style.display = 'block'; + + if (type !== 'error') { + setTimeout(() => { + statusEl.style.display = 'none'; + }, 5000); + } +} + +function showLoading(text = 'Processing...') { + const loadingTextEl = document.getElementById('loadingText'); + if (text.includes('
      ') || text.includes('')) { + loadingTextEl.innerHTML = text; + } else { + loadingTextEl.textContent = text; + } + document.getElementById('loadingOverlay').style.display = 'flex'; + +} + +function hideLoading() { + document.getElementById('loadingOverlay').style.display = 'none'; +} + +function updateStepIndicator(step) { + document.querySelectorAll('.step').forEach((el, index) => { + if (index + 1 < step) { + el.classList.add('completed'); + el.classList.remove('active'); + } else if (index + 1 === step) { + el.classList.add('active'); + el.classList.remove('completed'); + } else { + el.classList.remove('active', 'completed'); + } + }); + + document.querySelectorAll('.content-section').forEach(el => { + el.classList.remove('active'); + }); + document.getElementById(`step${step}`).classList.add('active'); + + currentStep = step; +} + +// ============================================ +// SECTION 6: EVENT HANDLERS +// ============================================ +// Upload area events +const uploadArea = document.getElementById('uploadArea'); +const fileInput = document.getElementById('fileInput'); + +uploadArea.addEventListener('click', () => fileInput.click()); + +uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); +}); + +uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); +}); + +uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + handleFiles(e.dataTransfer.files); +}); + +fileInput.addEventListener('change', (e) => { + handleFiles(e.target.files); +}); + +async function handleFiles(files) { + // Check if bot is selected + if (!validateOrganizationSelection()) { + return; + } + + const companyBotId = document.getElementById('companyBotSelect').value; + if (!companyBotId) { + showStatus('Please select a company bot before uploading files', 'error'); + return; + } + // Generate session ID if not exists + if (!sessionId) { + sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } + + const filesToUpload = []; + const unsupportedFiles = []; + + Array.from(files).forEach(file => { + // Check both MIME type and file extension + if (validTypes.includes(file.type) || (file.name && file.name.match(extensionRegex))) { + if (!uploadedFiles.find(f => f.name === file.name)) { + const fileData = { + file: file, + name: file.name, + size: file.size, + status: FILE_STATUS.PENDING, + error: null, + errorTimestamp: null, + index: uploadedFiles.length + }; + uploadedFiles.push(fileData); + filesToUpload.push(fileData); + } + } else { + unsupportedFiles.push(file.name); + } + }); + + // Show errors for unsupported files + if (unsupportedFiles.length > 0) { + const supportedTypes = fileTypesData.map(ft => ft.label).join(', '); + const unsupportedList = unsupportedFiles.join(', '); + showStatus( + `Unsupported file format${unsupportedFiles.length > 1 ? 's' : ''}: ${unsupportedList}. ` + + `Supported formats: ${supportedTypes}`, + 'error' + ); + } + + // Hide the upload area after files are selected + const uploadArea = document.getElementById('uploadArea'); + if (uploadArea) { + uploadArea.style.display = 'none'; + } + + // Show a message about adding more files + const uploadSection = document.querySelector('#step1 .upload-area').parentElement; + if (!document.getElementById('addMoreFilesMessage')) { + const addMoreMessage = document.createElement('div'); + addMoreMessage.id = 'addMoreFilesMessage'; + addMoreMessage.className = 'info-message'; + addMoreMessage.innerHTML = ` +
      + + + + + + Files added successfully! +
      +
      +

      + To add more files, please refresh the page. +

      +

      + + + + + + Warning: Refreshing the page will lose all currently uploaded files that haven't been saved to the database yet. +

      +
      +
      + +
      + `; + uploadSection.appendChild(addMoreMessage); + } + + // Update the file list display first to show pending files + updateFileList(); + + // If there are files to upload, process them immediately + if (filesToUpload.length > 0) { + showLoading(`Uploading ${filesToUpload.length} file(s)...`); + await processFiles(filesToUpload); + } else if (unsupportedFiles.length > 0 && filesToUpload.length === 0) { + // If all files were unsupported, show the upload area again + if (uploadedFiles.length === 0) { + uploadArea.style.display = 'block'; + const addMoreMessage = document.getElementById('addMoreFilesMessage'); + if (addMoreMessage) { + addMoreMessage.remove(); + } + } + } + + fileInput.value = ''; +} + +// New function to process files immediately after selection +async function processFiles(files) { + isExtracting = true; + updateExtractionProgress(); + document.getElementById('extractionProgress').classList.add('show'); + + const results = await extractDataFromFiles(files); + + // Add results to extractedData + results.forEach(result => { + const existingIndex = extractedData.findIndex(item => item.file_index === result.file_index); + if (existingIndex >= 0) { + extractedData[existingIndex] = result; + } else { + extractedData.push(result); + } + }); + + // Check if there are any AI extraction tasks to wait for + const tasksToWaitFor = extractedData + .filter(item => item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id && !item.data.auto_tags_ready) + .map(item => item.data.auto_tag_task_id); + + if (tasksToWaitFor.length > 0) { + isWaitingForAI = true; + document.getElementById('aiStatus').style.display = 'inline'; + + try { + await waitForAllTasksToComplete(tasksToWaitFor); + } catch (error) { + console.error('AI enhancement error:', error); + } finally { + isWaitingForAI = false; + document.getElementById('aiStatus').style.display = 'none'; + } + } + + isExtracting = false; + updateFileList(); + updateFailedLinks(); + updateButtonStates(); + + // Ensure loading overlay is hidden (this may be redundant now but good as safety) + hideLoading(); +} + +function isDocumentTypeField(key) { + return key && key.toUpperCase() === 'DOCUMENT TYPE'; +} + +function createDocumentTypeDropdown(path, kvIndex, currentValue = '') { + const dropdownId = `docTypeDropdown_${path}_${kvIndex}`; + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + + return ` +
      + + +
      + `; +} + +// Function to toggle document type dropdown +function toggleDocumentTypeDropdown(path, kvIndex) { + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + const dropdown = document.getElementById(listId); + if (!dropdown) return; + + const isVisible = dropdown.classList.contains('show'); + + // Close all other dropdowns + document.querySelectorAll('.dropdown-list').forEach(dl => { + dl.classList.remove('show'); + }); + + // Toggle current dropdown + if (!isVisible) { + dropdown.classList.add('show'); + filterDocumentTypeDropdown(path, kvIndex, ''); + } +} + +// Function to filter document type dropdown +function filterDocumentTypeDropdown(path, kvIndex, searchTerm) { + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + const dropdown = document.getElementById(listId); + if (!dropdown) return; + + const filteredTypes = masterDocumentTypes.filter(docType => + docType.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (filteredTypes.length > 0) { + dropdown.innerHTML = filteredTypes.map(docType => { + const escapedType = docType.replace(/'/g, "\\'"); + return ` + + `; + }).join(''); + } else if (searchTerm.trim()) { + dropdown.innerHTML = ` + + `; + } else { + dropdown.innerHTML = masterDocumentTypes.map(docType => { + const escapedType = docType.replace(/'/g, "\\'"); + return ` + + `; + }).join(''); + } + + dropdown.classList.add('show'); +} + +// Function to select document type from dropdown +function selectDocumentTypeFromDropdown(path, kvIndex, docType) { + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + const dropdown = document.getElementById(listId); + const searchInput = dropdown.previousElementSibling; + + dropdown.classList.remove('show'); + searchInput.value = docType; + + // Save the value based on whether this is a path (subdocument) or index (main document) + if (path.includes('_')) { + // This is a subdocument path + saveKeyValueByPath(path, kvIndex, 'value', docType); + } else { + // This is a main document index + saveKeyValueData(parseInt(path), kvIndex, 'value', docType); + } +} + +// Function to handle document type dropdown keypress +function handleDocumentTypeDropdownKeypress(event, path, kvIndex) { + if (event.key === 'Enter') { + event.preventDefault(); + const searchTerm = event.target.value.trim(); + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + const dropdown = document.getElementById(listId); + + if (searchTerm) { + // Check if exact match exists + const exactMatch = masterDocumentTypes.find(docType => + docType.toLowerCase() === searchTerm.toLowerCase() + ); + + if (exactMatch) { + selectDocumentTypeFromDropdown(path, kvIndex, exactMatch); + } else { + // Use the typed value as is (allows manual entry) + dropdown.classList.remove('show'); + + // Save based on whether this is a path (subdocument) or index (main document) + if (path.includes('_')) { + saveKeyValueByPath(path, kvIndex, 'value', searchTerm); + } else { + saveKeyValueData(parseInt(path), kvIndex, 'value', searchTerm); + } + } + } + } else if (event.key === 'Escape') { + const listId = `docTypeDropdownList_${path}_${kvIndex}`; + const dropdown = document.getElementById(listId); + dropdown.classList.remove('show'); + event.target.blur(); + } +} + +// Updated function for rendering key-values in subdocuments with document type dropdown +function updateKeyValueHtmlWithDocTypeDropdown(subdoc, path) { + return subdoc.key_values.map((kv, kvIndex) => { + const isDocType = isDocumentTypeField(kv.key); + const isOrganization = kv.key === 'ORGANIZATION'; + const isAiExtracted = kv.source === 'ai' || isOrganization || isDocType; + const isUserAdded = kv.source === 'user'; + + // Ensure kv.value is a string before processing + const kvValue = kv.value || ''; + const safeKvValue = typeof kvValue === 'string' ? kvValue : String(kvValue); + + // Enhanced textarea classes that consider structured content + const targetItem = { data: subdoc }; + const textareaClasses = getTextareaClasses(safeKvValue, kv.key, targetItem); + + // Key input styling and properties + const keyInputProps = isAiExtracted ? + 'readonly style="background-color: #f8f9fa; cursor: not-allowed;"' : + 'class="editable-field" onchange="saveKeyValueByPath(\'' + path + '\', ' + kvIndex + ', \'key\', this.value)"'; + + // Remove button visibility + const removeButtonStyle = isUserAdded ? '' : 'style="display: none;"'; + + if (isDocType) { + return ` +
      + +
      + ${createDocumentTypeDropdown(path, kvIndex, safeKvValue)} +
      + +
      + `; + } else if (isOrganization) { + return ` +
      + + + +
      + `; + } else { + // Add placeholder text for array fields + const isArrayField = shouldPreserveAsArray(safeKvValue, kv.key, targetItem); + const placeholder = isArrayField ? + "Enter list items (one per line, use • for bullet points)" : + "Value"; + + return ` +
      + + + +
      + `; + } + }).join(''); +} + +function isFormattedListContent(content) { + // Ensure content is a string before calling string methods + if (!content || typeof content !== 'string') { + return false; + } + + return content.includes('•') || + content.includes('\n') || + content.length > 200; +} + +function getTextareaClasses(content, key = null, item = null) { + let classes = 'key-value-textarea'; + + // Check if this should be treated as an array field + if (key && item && shouldPreserveAsArray(content, key, item)) { + classes += ' formatted-list'; + if (content && content.length > 200) { + classes += ' long-content'; + } + } else if (isFormattedListContent(content)) { + classes += ' formatted-list'; + if (content && content.length > 200) { + classes += ' long-content'; + } + } + + return classes; +} + + +// Updated function for rendering key-values in main documents with structured content support +function updateMainDocumentKeyValueHtml(item, displayIndex) { + return (item.data.key_values || []).map((kv, kvIndex) => { + const isDocType = isDocumentTypeField(kv.key); + const isOrganization = kv.key === 'ORGANIZATION'; + const isAiExtracted = kv.source === 'ai' || isOrganization || isDocType; // AI-extracted or special fields + const isUserAdded = kv.source === 'user'; + + // Ensure kv.value is a string before processing + const kvValue = kv.value || ''; + const safeKvValue = typeof kvValue === 'string' ? kvValue : String(kvValue); + + // Enhanced textarea classes that consider structured content + const textareaClasses = getTextareaClasses(safeKvValue, kv.key, item); + + // Key input styling and properties + // Key input styling and properties + const keyInputProps = isAiExtracted ? + 'readonly style="background-color: #f8f9fa; cursor: not-allowed;"' : + 'class="editable-field" onchange="saveKeyValueData(' + displayIndex + ', ' + kvIndex + ', \'key\', this.value)"'; + + // Remove button visibility + const removeButtonStyle = isUserAdded ? '' : 'style="display: none;"'; + + if (isDocType) { + return ` +
      + +
      + ${createDocumentTypeDropdown(displayIndex, kvIndex, safeKvValue)} +
      + +
      + `; + } else if (isOrganization) { + return ` +
      + + + +
      + `; + } else { + // Add placeholder text for array fields + const isArrayField = shouldPreserveAsArray(safeKvValue, kv.key, item); + const placeholder = isArrayField ? + "Enter list items (one per line, use • for bullet points)" : + "Value"; + + return ` +
      + + + +
      + `; + } + }).join(''); +} + +// Modified function to show files in separate sections +function updateFileList() { + const successfulFiles = uploadedFiles.filter(f => f.status === FILE_STATUS.SUCCESS || f.status === FILE_STATUS.PENDING || f.status === FILE_STATUS.PROCESSING); + const failedFiles = uploadedFiles.filter(f => f.status === FILE_STATUS.ERROR); + const skippedFiles = uploadedFiles.filter(f => f.status === FILE_STATUS.SKIPPED); + + // Get references to UI elements + const uploadArea = document.getElementById('uploadArea'); + const addMoreMessage = document.getElementById('addMoreFilesMessage'); + const successfulSection = document.getElementById('successfulSection'); + const successfulList = document.getElementById('successfulFilesList'); + const successfulCount = document.getElementById('successfulCount'); + const failedSection = document.getElementById('failedSection'); + const failedList = document.getElementById('failedFilesList'); + const failedCount = document.getElementById('failedCount'); + + // Check if we have any files at all + const hasAnyFiles = uploadedFiles.length > 0; + + // Show/hide upload area based on whether we have files + if (!hasAnyFiles) { + // No files at all - show upload area, hide message + if (uploadArea) { + uploadArea.style.display = 'block'; + } + if (addMoreMessage) { + addMoreMessage.remove(); + } + } else { + // Have files - hide upload area, show message + if (uploadArea) { + uploadArea.style.display = 'none'; + } + + // Add "add more files" message if not already present + if (!addMoreMessage) { + const uploadSection = document.querySelector('#step1 .upload-area').parentElement; + const newMessage = document.createElement('div'); + newMessage.id = 'addMoreFilesMessage'; + newMessage.className = 'info-message'; + newMessage.innerHTML = ` +
      + + + + + + Files added successfully! +
      +
      +

      + To add more files, please refresh the page. +

      +

      + + + + + + Warning: Refreshing the page will lose all currently uploaded files that haven't been saved to the database yet. +

      +
      +
      + +
      + `; + uploadSection.appendChild(newMessage); + } + } + + // Update successful files section + if (successfulFiles.length > 0 || skippedFiles.length > 0) { + if (successfulSection) { + successfulSection.style.display = 'block'; + } + if (successfulCount) { + successfulCount.textContent = `(${successfulFiles.length})`; + } + + if (successfulList) { + successfulList.innerHTML = ''; + [...successfulFiles, ...skippedFiles].forEach(fileData => { + const fileItem = createFileItem(fileData); + successfulList.appendChild(fileItem); + }); + } + } else { + if (successfulSection) { + successfulSection.style.display = 'none'; + } + } + + // Update failed files section + if (failedFiles.length > 0) { + if (failedSection) { + failedSection.style.display = 'block'; + } + if (failedCount) { + failedCount.textContent = `(${failedFiles.length})`; + } + + const sectionHeader = failedSection ? failedSection.querySelector('.section-header') : null; + if (sectionHeader) { + sectionHeader.innerHTML = ` +
      + Failed Uploads + (${failedFiles.length}) +
      + `; + } + + if (failedList) { + failedList.innerHTML = ''; + failedFiles.forEach(fileData => { + const fileItem = createFileItem(fileData); + failedList.appendChild(fileItem); + }); + } + } else { + if (failedSection) { + failedSection.style.display = 'none'; + } + } + + updateButtonStates(); + updateExtractionProgress(); + updateFailedLinks(); // Always update failed links when file list updates +} + +function updateFailedLinks() { + // Find all files with failed links + let allFailedLinks = []; + + extractedData.forEach((item, fileIndex) => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.failed_links) { + item.data.failed_links.forEach((failedLink, linkIndex) => { + // Extract the URL from the failed link structure + let url = ''; + if (failedLink.file_url) { + url = failedLink.file_url; + } else if (failedLink.url && Array.isArray(failedLink.url) && failedLink.url.length > 0) { + url = failedLink.url[0]; + } else if (typeof failedLink.url === 'string') { + url = failedLink.url; + } + + // Extract error message + let errorMessage = ''; + if (failedLink.error) { + if (typeof failedLink.error === 'object' && failedLink.error.error) { + errorMessage = failedLink.error.error; + } else if (typeof failedLink.error === 'string') { + errorMessage = failedLink.error; + } + } + + allFailedLinks.push({ + ...failedLink, + url: url, + errorMessage: errorMessage, + parentFileIndex: fileIndex, + parentFileName: item.filename, + uniqueIndex: `${fileIndex}_${linkIndex}` // Create unique identifier + }); + }); + } + }); + + const fileSections = document.getElementById('fileSections'); + + // Remove existing failed links section + const existingSection = document.getElementById('failedLinksSection'); + if (existingSection) { + existingSection.remove(); + } + + if (allFailedLinks.length > 0) { + const failedLinksSection = document.createElement('div'); + failedLinksSection.id = 'failedLinksSection'; + failedLinksSection.className = 'failed-links-section'; + + failedLinksSection.innerHTML = ` + + + `; + + fileSections.appendChild(failedLinksSection); + } +} + +// Add retry function for failed links +async function retryFailedLink(parentFileIndex, url) { + showStatus(`Retrying extraction for ${url}...`, 'info'); + + // Here you would implement the retry logic + // This is a placeholder - you'd need to call your backend to retry + showStatus('Retry functionality not yet implemented', 'warning'); +} + + +function removeFailedLink(parentFileIndex, encodedUrl, failedIndex) { + const url = decodeURIComponent(encodedUrl); + const item = extractedData[parentFileIndex]; + + if (item && item.data && item.data.failed_links) { + // Find and remove the failed link by URL + const originalLength = item.data.failed_links.length; + item.data.failed_links = item.data.failed_links.filter(link => { + let linkUrl = ''; + if (link.file_url) { + linkUrl = link.file_url; + } else if (link.url && Array.isArray(link.url) && link.url.length > 0) { + linkUrl = link.url[0]; + } else if (typeof link.url === 'string') { + linkUrl = link.url; + } + return linkUrl !== url; + }); + + // Check if the link was actually removed + if (item.data.failed_links.length < originalLength) { + // Mark item as having unsaved changes + item.hasUnsavedChanges = true; + + // Update the failed links display + updateFailedLinks(); + + // Also update the file list to reflect changes + updateFileList(); + + showStatus(`Removed failed link: ${url}`, 'success'); + } else { + showStatus(`Failed to remove link: ${url}`, 'error'); + } + } else { + showStatus('Failed to find the link to remove', 'error'); + } +} + +// New function to create individual file items +function createFileItem(fileData) { + const fileItem = document.createElement('div'); + fileItem.className = `file-item ${fileData.status}`; + fileItem.id = `file-item-${fileData.index}`; + + let statusIcon = ''; + let statusText = ''; + let statusActions = ''; + + switch (fileData.status) { + case FILE_STATUS.SUCCESS: + statusIcon = '✓'; + statusText = 'Upload successful'; + break; + case FILE_STATUS.ERROR: + statusIcon = '✗'; + statusText = 'Upload failed'; + statusActions = ` + + `; + break; + case FILE_STATUS.SKIPPED: + statusIcon = '⊘'; + statusText = 'Skipped'; + break; + case FILE_STATUS.PROCESSING: + statusIcon = ''; + statusText = 'Uploading...'; + break; + default: + statusIcon = '⏳'; + statusText = 'Waiting...'; + } + + fileItem.innerHTML = ` +
      +
      +
      ${statusIcon}
      +
      +
      ${fileData.name}
      +
      ${(fileData.size / 1024).toFixed(2)} KB
      +
      ${statusText}
      +
      +
      +
      + ${statusActions} + +
      +
      + ${fileData.status === FILE_STATUS.ERROR ? ` +
      +
      +
      Upload Error
      +
      ${fileData.error || 'Unknown error occurred'}
      +
      Failed at: ${fileData.errorTimestamp ? new Date(fileData.errorTimestamp).toLocaleString() : 'Unknown time'}
      +
      +
      + ` : ''} + `; + + return fileItem; +} + +function toggleErrorDetails(index) { + const errorDetails = document.getElementById(`error-details-${index}`); + if (errorDetails) { + errorDetails.classList.toggle('show'); + } +} + +function updateButtonStates() { + const hasReadyFiles = uploadedFiles.some(f => f.status === FILE_STATUS.SUCCESS); + const isProcessing = uploadedFiles.some(f => f.status === FILE_STATUS.PROCESSING); + const hasFiles = uploadedFiles.length > 0; + + document.getElementById('proceedBtn').disabled = !hasReadyFiles || isProcessing || isWaitingForAI; + + // Show/hide proceed button based on upload state + const proceedBtn = document.getElementById('proceedBtn'); + + if (hasReadyFiles && !isProcessing && !isWaitingForAI) { + proceedBtn.style.display = 'inline-block'; + } else { + proceedBtn.style.display = 'none'; + } +} + +function updateExtractionProgress() { + const stats = uploadedFiles.reduce((acc, file) => { + acc.total++; + switch (file.status) { + case FILE_STATUS.SUCCESS: + acc.success++; + break; + case FILE_STATUS.ERROR: + acc.error++; + break; + case FILE_STATUS.PROCESSING: + acc.processing++; + break; + case FILE_STATUS.SKIPPED: + acc.skipped++; + break; + } + return acc; + }, { total: 0, success: 0, error: 0, processing: 0, skipped: 0 }); + + // Update progress stats + document.getElementById('successCount').textContent = stats.success; + document.getElementById('errorCount').textContent = stats.error; + + // Update progress bar + const completed = stats.success + stats.error + stats.skipped; + const progress = stats.total > 0 ? (completed / stats.total) * 100 : 0; + document.getElementById('extractionProgressBar').style.width = progress + '%'; + + // Update progress text + const progressText = document.getElementById('progressText'); + if (stats.processing > 0) { + progressText.textContent = `Processing ${stats.processing} file(s)...`; + } else if (completed === stats.total && stats.total > 0) { + progressText.textContent = `Upload complete! ${stats.success} successful, ${stats.error} failed, ${stats.skipped} skipped.`; + } else if (stats.total > 0) { + progressText.textContent = `${completed}/${stats.total} files processed`; + } else { + progressText.textContent = 'No files to process'; + } + + // Show/hide progress section + const progressSection = document.getElementById('extractionProgress'); + if (uploadedFiles.length > 0 && (completed > 0 || stats.processing > 0)) { + progressSection.classList.add('show'); + } else { + progressSection.classList.remove('show'); + } +} + +function removeFile(index) { + if (!confirm('Are you sure you want to remove this file from the upload list?')) { + return; + } + // Remove from uploadedFiles + uploadedFiles = uploadedFiles.filter(f => f.index !== index); + + // Also remove from extractedData + extractedData = extractedData.filter(item => item.file_index !== index); + + // Reindex remaining files + uploadedFiles.forEach((file, newIndex) => { + file.index = newIndex; + // Update corresponding extracted data index + const extractedItem = extractedData.find(item => item.filename === file.name); + if (extractedItem) { + extractedItem.file_index = newIndex; + } + }); + + // Update the file list - this will handle showing upload area if no files remain + updateFileList(); + + // If no files remain, also ensure we're back to step 1 + if (uploadedFiles.length === 0) { + updateStepIndicator(1); + // Clear session data + sessionId = null; + extractedData = []; + + // Hide progress section + const progressSection = document.getElementById('extractionProgress'); + if (progressSection) { + progressSection.classList.remove('show'); + } + + showStatus('All files removed. You can now upload new files.', 'info'); + } +} + +function skipFile(index) { + uploadedFiles[index].status = FILE_STATUS.SKIPPED; + uploadedFiles[index].error = null; + uploadedFiles[index].errorTimestamp = null; + updateFileList(); +} + +function unSkipFile(index) { + uploadedFiles[index].status = FILE_STATUS.ERROR; + uploadedFiles[index].error = 'Previously skipped - click Upload Again to retry'; + uploadedFiles[index].errorTimestamp = null; + updateFileList(); +} + +// Retry all failed uploads +async function retryAllFailedUploads() { + const failedFiles = uploadedFiles.filter(f => f.status === FILE_STATUS.ERROR); + + if (failedFiles.length === 0) { + showStatus('No failed uploads to retry', 'info'); + return; + } + + showLoading(`Uploading ${failedFiles.length} failed files...`); + + // Process all failed files + await processFiles(failedFiles); + + // hideLoading is now called inside processFiles +} + +// Retry all failed saves +async function retryAllFailed() { + // Collect all failed items including subdocuments + const failedItems = []; + + // Add main document failures + saveResults.forEach((result, index) => { + if (!result.success) { + failedItems.push({ + type: 'main', + index: index, + result: result + }); + } + }); + + // Add subdocument failures + saveResults.forEach((result, parentIndex) => { + if (result.subdocument_results) { + function collectFailedSubdocs(subdocResults, parentIdx) { + subdocResults.forEach(subdoc => { + if (!subdoc.success) { + failedItems.push({ + type: 'subdoc', + parentIndex: parentIdx, + path: subdoc.path, + cacheKey: subdoc.cache_key, + title: subdoc.title + }); + } + if (subdoc.nested_subdocument_results) { + collectFailedSubdocs(subdoc.nested_subdocument_results, parentIdx); + } + }); + } + collectFailedSubdocs(result.subdocument_results, parentIndex); + } + }); + + if (failedItems.length === 0) { + showStatus('No failed saves to retry', 'info'); + return; + } + + showLoading(`Retrying ${failedItems.length} failed saves...`); + + let successCount = 0; + let failCount = 0; + + for (const item of failedItems) { + try { + if (item.type === 'main') { + // Existing main document retry logic + let itemData; + if (item.result.originalData) { + itemData = item.result.originalData; + } else { + const originalItem = extractedData.find(dataItem => + dataItem.filename === item.result.filename && dataItem.status === FILE_STATUS.SUCCESS + ); + if (!originalItem) { + failCount++; + continue; + } + itemData = { + ...originalItem.data, + filename: originalItem.filename, + file_index: originalItem.file_index, + manual_tags: originalItem.data.manual_tags || [], + auto_tags: originalItem.data.auto_tags || [], + file_key: originalItem.data.file_key, + session_id: originalItem.data.session_id || sessionId, + subdocument: originalItem.data.subdocument || [], + images: originalItem.data.images || [] + }; + } + + const response = await fetch("{% url 'admin:chatbot_media_retry_save' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ + item_data: itemData, + company_bot_id: document.getElementById('companyBotSelect').value, + session_id: sessionId + }) + }); + + const retryResult = await response.json(); + if (retryResult.success) { + saveResults[item.index] = { + ...retryResult.result, + originalData: itemData + }; + successCount++; + } else { + failCount++; + } + } else if (item.type === 'subdoc') { + // Retry subdocument + await retrySubdocSave(item.parentIndex, item.path, item.cacheKey); + successCount++; + } + + showLoading(`Retrying saves... ${successCount + failCount}/${failedItems.length} processed`); + + } catch (error) { + failCount++; + console.error(`Retry failed:`, error); + } + } + + hideLoading(); + displayResults(saveResults); + + if (successCount > 0 && failCount === 0) { + showStatus(`Successfully retried all ${successCount} failed saves!`, 'success'); + } else if (successCount > 0 && failCount > 0) { + showStatus(`Retried ${successCount} saves successfully, ${failCount} still failed.`, 'warning'); + } else { + showStatus(`All ${failCount} retry attempts failed.`, 'error'); + } +} + +async function retryExtraction(index) { + const fileData = uploadedFiles[index]; + + // Set status to processing + uploadedFiles[index].status = FILE_STATUS.PROCESSING; + uploadedFiles[index].error = null; + uploadedFiles[index].errorTimestamp = null; + updateFileList(); + + try { + const requestData = { + file_data: { + filename: fileData.name, + file_index: index, + file_key: fileData.fileKey, + size: fileData.size + }, + company_bot_id: document.getElementById('companyBotSelect').value, + session_id: fileData.sessionId || sessionId + }; + + console.log('Retry request data:', requestData); + + const response = await fetch("{% url 'admin:chatbot_media_retry_extract' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + if (result.success) { + // Update the file status + uploadedFiles[index].status = FILE_STATUS.SUCCESS; + uploadedFiles[index].error = null; + uploadedFiles[index].errorTimestamp = null; + + // Ensure organization is set + if (!result.data.organization) { + result.data.organization = userCompanyName || ''; + } + if (!result.data.key_values) { + result.data.key_values = []; + } + if (!result.data.key_values.some(kv => kv.key === 'ORGANIZATION')) { + result.data.key_values.push({ + key: 'ORGANIZATION', + value: result.data.organization + }); + } + + // Update or add to extracted data + const existingIndex = extractedData.findIndex(item => item && item.file_index === index); + if (existingIndex >= 0) { + extractedData[existingIndex] = { + id: `temp_${Date.now()}_${index}`, + filename: fileData.name, + file: fileData.file, + file_index: index, + status: FILE_STATUS.SUCCESS, + data: result.data + }; + } else { + extractedData.push({ + id: `temp_${Date.now()}_${index}`, + filename: fileData.name, + file: fileData.file, + file_index: index, + status: FILE_STATUS.SUCCESS, + data: result.data + }); + } + + // Check if AI task needs to be waited for + if (result.data && result.data.auto_tag_task_id && !result.data.auto_tags_ready) { + isWaitingForAI = true; + document.getElementById('aiStatus').style.display = 'inline'; + + try { + await waitForAllTasksToComplete([result.data.auto_tag_task_id]); + } catch (error) { + console.error('AI enhancement error:', error); + } finally { + isWaitingForAI = false; + document.getElementById('aiStatus').style.display = 'none'; + } + } + + updateFileList(); + showStatus(`Successfully uploaded ${fileData.name}`, 'success'); + } else { + uploadedFiles[index].status = FILE_STATUS.ERROR; + uploadedFiles[index].error = result.error; + uploadedFiles[index].errorTimestamp = new Date().toISOString(); + showStatus(`Upload failed for ${fileData.name}: ${result.error}`, 'error'); + updateFileList(); + } + } catch (error) { + uploadedFiles[index].status = FILE_STATUS.ERROR; + uploadedFiles[index].error = error.message; + uploadedFiles[index].errorTimestamp = new Date().toISOString(); + showStatus(`Upload failed for ${fileData.name}: ${error.message}`, 'error'); + updateFileList(); + } finally { + updateFailedLinks(); + } +} + +// Company bot selection +document.getElementById('companyBotSelect').addEventListener('change', updateFileList); + +// Data upload via API +async function extractDataFromFiles(files) { + const companyBotId = document.getElementById('companyBotSelect').value; + const extractedItems = []; + + // Generate session ID if not exists + if (!sessionId) { + sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } + + // Process files one by one to show progress + for (let i = 0; i < files.length; i++) { + const fileData = files[i]; + const fileIndex = fileData.index; + + if (fileData.status === FILE_STATUS.SKIPPED) { + // Add placeholder for skipped files + extractedItems.push({ + id: `skipped_${fileIndex}`, + filename: fileData.name, + file: fileData.file, + file_index: fileIndex, + status: FILE_STATUS.SKIPPED, + data: null + }); + continue; + } + + // Set status to processing + uploadedFiles[fileIndex].status = FILE_STATUS.PROCESSING; + updateFileList(); + + try { + // Create FormData for single file + const formData = new FormData(); + formData.append('files', fileData.file); + formData.append('file_indices', fileIndex); + formData.append('company_bot_id', companyBotId); + formData.append('session_id', sessionId); + + console.log(`Uploading file ${i + 1}/${files.length}: ${fileData.name} with index ${fileIndex}`); + + const response = await fetch("{% url 'admin:chatbot_media_batch_extract' %}", { + method: 'POST', + headers: { + 'X-CSRFToken': csrftoken, + }, + body: formData + }); + + const result = await response.json(); + if (result.success && result.data.length > 0) { + const uploadResult = result.data[0]; // Single file result + + // Store session_id from response + if (result.session_id) { + sessionId = result.session_id; + } + + if (uploadResult.status === 'success') { + uploadedFiles[fileIndex].status = FILE_STATUS.SUCCESS; + uploadedFiles[fileIndex].error = null; + uploadedFiles[fileIndex].errorTimestamp = null; + uploadedFiles[fileIndex].sessionId = sessionId; + uploadedFiles[fileIndex].fileKey = uploadResult.file_key; + + // Ensure organization is set + uploadResult.organization = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + if (!uploadResult.key_values) { + uploadResult.key_values = []; + } + const orgKvIndex = uploadResult.key_values.findIndex(kv => kv.key === 'ORGANIZATION'); + if (orgKvIndex >= 0) { + uploadResult.key_values[orgKvIndex].value = uploadResult.organization; + } else { + uploadResult.key_values.unshift({ + key: 'ORGANIZATION', + value: uploadResult.organization, + source: 'ai' + }); + } + + extractedItems.push({ + id: `temp_${Date.now()}_${fileIndex}`, + filename: fileData.name, + file: fileData.file, + file_index: fileIndex, + status: FILE_STATUS.SUCCESS, + data: uploadResult + }); + } else { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = uploadResult.error; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + uploadedFiles[fileIndex].sessionId = sessionId; + uploadedFiles[fileIndex].fileKey = uploadResult.file_key; + + extractedItems.push({ + id: `error_${fileIndex}`, + filename: fileData.name, + file: fileData.file, + file_index: fileIndex, + status: FILE_STATUS.ERROR, + data: uploadResult, + error: uploadResult.error + }); + } + } else { + throw new Error(result.error || 'Failed to extract data'); + } + } catch (error) { + console.error(`Error processing file ${fileData.name}:`, error); + + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = error.message; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + + extractedItems.push({ + id: `error_${fileIndex}`, + filename: fileData.name, + file: fileData.file, + file_index: fileIndex, + status: FILE_STATUS.ERROR, + data: null, + error: error.message + }); + } + + updateFileList(); + + // Small delay to show progress + await new Promise(resolve => setTimeout(resolve, 200)); + } + + return extractedItems; +} + +function ensureSubdocumentOrganization(subdoc, parentOrg) { + // Use selected organization instead of parent's extracted organization + const orgToUse = selectedOrganization ? selectedOrganization.name : (parentOrg || userCompanyName || ''); + subdoc.organization = orgToUse; + + // Update or add organization in key-values + if (!subdoc.key_values) { + subdoc.key_values = []; + } + + // Find existing ORGANIZATION key-value + let orgKvIndex = subdoc.key_values.findIndex(kv => kv.key === 'ORGANIZATION'); + + if (orgKvIndex >= 0) { + // Update existing organization to selected value + subdoc.key_values[orgKvIndex].value = subdoc.organization; + } else { + // Add organization key-value + subdoc.key_values.unshift({ + key: 'ORGANIZATION', + value: subdoc.organization + }); + } + + // Process nested subdocuments recursively + if (subdoc.subdocument && Array.isArray(subdoc.subdocument)) { + subdoc.subdocument.forEach(nestedSub => { + // Pass the same selected organization down + ensureSubdocumentOrganization(nestedSub, subdoc.organization); + }); + } +} + +// Wait for all AI tasks to complete +async function waitForAllTasksToComplete(taskIds) { + return new Promise((resolve, reject) => { + const totalTasks = taskIds.length; + let checkAttempts = 0; + const maxAttempts = 300; // High limit to handle very long tasks + let startTime = Date.now(); + + const scheduleNextCheck = () => { + // Calculate interval based on elapsed time + const elapsedMinutes = (Date.now() - startTime) / (1000 * 60); + const interval = elapsedMinutes < 2 ? 5000 : 30000; // 5s for first 2 min, then 30s + + setTimeout(async () => { + checkAttempts++; + + try { + const response = await fetch("{% url 'admin:chatbot_media_task_status' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ task_ids: taskIds }) + }); + + const result = await response.json(); + if (result.success) { + let completedTasks = 0; + + extractedData.forEach(item => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id) { + const taskResult = result.results[item.data.auto_tag_task_id]; + + if (taskResult && taskResult.status === 'SUCCESS' && !item.data.auto_tags_ready) { + // Process the enhanced AI data + const aiResult = taskResult.result; + + // Update auto tags + const autoTags = aiResult.auto_tags || []; + item.data.auto_tags = autoTags.map(tag => { + return typeof tag === 'object' ? tag.text : tag; + }); + item.data.auto_tags_full = autoTags; + item.data.auto_tags_ready = true; + + // Update enhanced data if available + if (aiResult.enhanced_data) { + const enhanced = aiResult.enhanced_data; + + if (enhanced.description) item.data.description = enhanced.description; + if (enhanced.hasOwnProperty('extracted_text')) item.data.extracted_text = enhanced.extracted_text; + if (enhanced.media_type) item.data.media_type = enhanced.media_type; + + if (enhanced.url && Array.isArray(enhanced.url)) { + item.data.url = enhanced.url; + } + // Merge enhanced key-values with existing ones + // Merge enhanced key-values with existing ones, but preserve user-selected organization + if (enhanced.enhanced_key_values && enhanced.enhanced_key_values.length > 0) { + const basicKVs = item.data.key_values.filter(kv => + kv.key === 'FILE TYPE' || kv.key === 'FILE SIZE' + ); + + // Filter out AI-extracted organization from enhanced key-values + const enhancedKVsWithoutOrg = enhanced.enhanced_key_values.filter(kv => kv.key !== 'ORGANIZATION'); + + // Keep user-selected organization + const userSelectedOrg = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + enhancedKVsWithoutOrg.unshift({ + key: 'ORGANIZATION', + value: userSelectedOrg, + source: 'ai' + }); + + item.data.key_values = [...basicKVs, ...enhancedKVsWithoutOrg]; + } else { + // Ensure organization reflects user selection + const userSelectedOrg = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + if (!item.data.key_values.some(kv => kv.key === 'ORGANIZATION')) { + item.data.key_values.push({ + key: 'ORGANIZATION', + value: userSelectedOrg, + source: 'ai' + }); + } else { + // Update existing organization key-value to user selection + const orgKvIndex = item.data.key_values.findIndex(kv => kv.key === 'ORGANIZATION'); + if (orgKvIndex >= 0) { + item.data.key_values[orgKvIndex].value = userSelectedOrg; + } + } + } + + // Ensure main organization field reflects user selection, not AI extraction + item.data.organization = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + + // Update subdocuments if available - FORCE user-selected organization + if (enhanced.subdocument && Array.isArray(enhanced.subdocument)) { + const userSelectedOrg = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + + enhanced.subdocument.forEach(subdoc => { + ensureSubdocumentOrganization(subdoc, userSelectedOrg); + }); + item.data.subdocument = enhanced.subdocument; + } + + // Update images if available + if (enhanced.images && Array.isArray(enhanced.images)) { + item.data.images = enhanced.images; + } + if (enhanced.failed_links && Array.isArray(enhanced.failed_links)) { + item.data.failed_links = enhanced.failed_links; + } + // Update source_documents if available + if (enhanced.source_documents && Array.isArray(enhanced.source_documents)) { + item.data.source_documents = enhanced.source_documents; + } + } + + console.log(`AI extraction completed for ${item.filename}:`, aiResult); + } else if (taskResult && (taskResult.status === 'FAILURE' || taskResult.status === 'ERROR') && !item.data.auto_tags_ready) { + // *** CHANGED: Mark entire document as failed *** + const errorMsg = taskResult.error || 'AI processing failed'; + + // Find the corresponding file in uploadedFiles and mark as failed + const fileIndex = uploadedFiles.findIndex(f => f.name === item.filename); + if (fileIndex >= 0) { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = errorMsg; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + } + + // Mark the extracted data item as failed + item.status = FILE_STATUS.ERROR; + item.error = errorMsg; + + // Remove from successful extractedData + const extractedIndex = extractedData.findIndex(ed => ed.filename === item.filename); + if (extractedIndex >= 0) { + extractedData[extractedIndex].status = FILE_STATUS.ERROR; + extractedData[extractedIndex].error = errorMsg; + } + + console.error(`AI extraction failed for ${item.filename}:`, errorMsg); + } + + if (item.data.auto_tags_ready || item.status === FILE_STATUS.ERROR) { + completedTasks++; + } + } + }); + + updateFailedLinks(); + + // Update loading message with progress + showLoading(`AI extraction in progress... ${completedTasks}/${totalTasks} completed`); + + // Check if all tasks are complete (including failed ones) + if (completedTasks >= totalTasks) { + console.log(`AI extraction finished. Completed: ${completedTasks}/${totalTasks} in ${checkAttempts} API calls`); + hideLoading(); + + // *** CHANGED: Update file list to show failed documents, no success message *** + updateFileList(); + updateButtonStates(); + + setTimeout(() => { + resolve(); + }, 100); + return; + } + + // Check if we've exceeded max attempts + if (checkAttempts >= maxAttempts) { + console.warn(`AI extraction timed out after ${checkAttempts} attempts`); + hideLoading(); + + // *** CHANGED: Mark remaining tasks as failed due to timeout *** + extractedData.forEach(item => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id && !item.data.auto_tags_ready) { + const errorMsg = 'AI processing timed out'; + + // Find the corresponding file in uploadedFiles and mark as failed + const fileIndex = uploadedFiles.findIndex(f => f.name === item.filename); + if (fileIndex >= 0) { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = errorMsg; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + } + + // Mark the extracted data item as failed + item.status = FILE_STATUS.ERROR; + item.error = errorMsg; + } + }); + + updateFileList(); + updateButtonStates(); + resolve(); + return; + } + + // Schedule next check + scheduleNextCheck(); + } else { + // *** CHANGED: Handle API call failures by marking as failed *** + console.error('API call failed:', result); + if (checkAttempts >= maxAttempts) { + hideLoading(); + + // Mark all pending AI tasks as failed + extractedData.forEach(item => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id && !item.data.auto_tags_ready) { + const errorMsg = 'AI processing failed to complete'; + + // Find the corresponding file in uploadedFiles and mark as failed + const fileIndex = uploadedFiles.findIndex(f => f.name === item.filename); + if (fileIndex >= 0) { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = errorMsg; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + } + + // Mark the extracted data item as failed + item.status = FILE_STATUS.ERROR; + item.error = errorMsg; + } + }); + + updateFileList(); + updateButtonStates(); + resolve(); + return; + } + // Retry on API failure + scheduleNextCheck(); + } + } catch (error) { + console.error('Error checking task status:', error); + if (checkAttempts >= maxAttempts) { + hideLoading(); + + // *** CHANGED: Mark all pending AI tasks as failed *** + extractedData.forEach(item => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id && !item.data.auto_tags_ready) { + const errorMsg = 'AI processing encountered an error'; + + // Find the corresponding file in uploadedFiles and mark as failed + const fileIndex = uploadedFiles.findIndex(f => f.name === item.filename); + if (fileIndex >= 0) { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = errorMsg; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + } + + // Mark the extracted data item as failed + item.status = FILE_STATUS.ERROR; + item.error = errorMsg; + } + }); + + updateFileList(); + updateButtonStates(); + resolve(); + return; + } + // Retry on error + scheduleNextCheck(); + } + }, interval); + }; + + // Start the checking process + scheduleNextCheck(); + + // Overall safety timeout (2 hours) + setTimeout(() => { + console.warn('AI extraction timed out after 2 hours'); + hideLoading(); + + // *** CHANGED: Mark all pending AI tasks as failed due to overall timeout *** + extractedData.forEach(item => { + if (item.status === FILE_STATUS.SUCCESS && item.data && item.data.auto_tag_task_id && !item.data.auto_tags_ready) { + const errorMsg = 'AI processing timed out after 2 hours'; + + // Find the corresponding file in uploadedFiles and mark as failed + const fileIndex = uploadedFiles.findIndex(f => f.name === item.filename); + if (fileIndex >= 0) { + uploadedFiles[fileIndex].status = FILE_STATUS.ERROR; + uploadedFiles[fileIndex].error = errorMsg; + uploadedFiles[fileIndex].errorTimestamp = new Date().toISOString(); + } + + // Mark the extracted data item as failed + item.status = FILE_STATUS.ERROR; + item.error = errorMsg; + } + }); + + updateFileList(); + updateButtonStates(); + resolve(); + }, 7200000); // 2 hours + }); +} + +// Proceed button handler +document.getElementById('proceedBtn').addEventListener('click', () => { + // Filter out skipped files for pagination + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + + if (validItems.length === 0) { + showStatus('No files available for review. Please upload files first.', 'warning'); + return; + } + + totalPages = Math.ceil(validItems.length / itemsPerPage); + currentPage = 1; + + renderExtractedData(); + updateStepIndicator(2); +}); + + +// Add this function to support recursive subdocument rendering +function renderNestedSubdocuments(subdocs, parentPath, baseParentIndex) { + if (!subdocs || !Array.isArray(subdocs) || subdocs.length === 0) { + return ''; + } + + return subdocs.map((subdoc, index) => { + const currentPath = `${parentPath}_${index}`; + return renderSubdocumentItemRecursive(subdoc, currentPath, baseParentIndex); + }).join(''); +} + +function addTagFromDropdownByPath(path, tagValue) { + if (!tagValue) return; + + const subdoc = getSubdocByPath(path); + if (subdoc) { + if (!subdoc.manual_tags) { + subdoc.manual_tags = []; + } + + if (!subdoc.manual_tags.includes(tagValue)) { + subdoc.manual_tags.push(tagValue); + subdoc.hasUnsavedChanges = true; + + // Add the tag element directly + const container = document.getElementById(`manualTagsContainer_${path}`); + if (container) { + const newTag = document.createElement('span'); + newTag.className = 'tag manual'; + newTag.innerHTML = ` + ${tagValue} + × + `; + const wrapper = container.querySelector('.tag-input-wrapper'); + container.insertBefore(newTag, wrapper); + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + + // Reset dropdown + const dropdown = document.getElementById(`tagDropdown_${path}`); + if (dropdown) { + dropdown.value = ''; + } + } +} + +function toggleTagDropdown(identifier) { + const dropdown = document.getElementById(`tagDropdownList_${identifier}`); + if (!dropdown) return; + + const isVisible = dropdown.classList.contains('show'); + + // Close all other tag dropdowns + document.querySelectorAll('.dropdown-list').forEach(dl => { + dl.classList.remove('show'); + }); + + // Toggle current dropdown + if (!isVisible) { + dropdown.classList.add('show'); + // Show all options initially + filterTagDropdown(identifier, ''); + } +} + +function filterTagDropdown(identifier, searchTerm) { + const dropdown = document.getElementById(`tagDropdownList_${identifier}`); + if (!dropdown) return; + + const filteredTags = existingManualTags.filter(tag => + tag.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (filteredTags.length > 0) { + dropdown.innerHTML = filteredTags.map(tag => { + const escapedTag = tag.replace(/'/g, "\\'"); + return ` + + `; + }).join(''); + } else if (searchTerm.trim()) { + dropdown.innerHTML = ` + + `; + } else { + dropdown.innerHTML = existingManualTags.map(tag => { + const escapedTag = tag.replace(/'/g, "\\'"); + return ` + + `; + }).join(''); + } + + dropdown.classList.add('show'); +} + +function selectTagFromDropdown(identifier, tagValue) { + // Close dropdown + const dropdown = document.getElementById(`tagDropdownList_${identifier}`); + const searchInput = dropdown.previousElementSibling; + + dropdown.classList.remove('show'); + searchInput.value = ''; + + // Determine if this is a path (subdocument) or index (main item) + if (identifier.includes('_')) { + // This is a path for subdocument + addTagFromDropdownByPath(identifier, tagValue); + } else { + // This is an index for main item + addTagFromDropdown(parseInt(identifier), tagValue); + } +} + +function handleTagDropdownKeypress(event, identifier) { + if (event.key === 'Enter') { + event.preventDefault(); + const searchTerm = event.target.value.trim(); + const dropdown = document.getElementById(`tagDropdownList_${identifier}`); + + if (searchTerm) { + // Check if exact match exists + const exactMatch = existingManualTags.find(tag => + tag.toLowerCase() === searchTerm.toLowerCase() + ); + + if (exactMatch) { + selectTagFromDropdown(identifier, exactMatch); + } else { + // Add as new tag + dropdown.classList.remove('show'); + event.target.value = ''; + + // Determine if this is a path (subdocument) or index (main item) + if (identifier.includes('_')) { + // This is a path for subdocument + const subdoc = getSubdocByPath(identifier); + if (subdoc && searchTerm) { + if (!subdoc.manual_tags) { + subdoc.manual_tags = []; + } + + if (!subdoc.manual_tags.includes(searchTerm)) { + subdoc.manual_tags.push(searchTerm); + subdoc.hasUnsavedChanges = true; + + // Add the tag element directly + const container = document.getElementById(`manualTagsContainer_${identifier}`); + if (container) { + const newTag = document.createElement('span'); + newTag.className = 'tag manual'; + newTag.innerHTML = ` + ${searchTerm} + × + `; + const wrapper = container.querySelector('.tag-input-wrapper'); + container.insertBefore(newTag, wrapper); + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${identifier}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + } + } else { + // This is an index for main item + const itemIndex = parseInt(identifier); + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item && searchTerm) { + if (!item.data.manual_tags) { + item.data.manual_tags = []; + } + + if (!item.data.manual_tags.includes(searchTerm)) { + item.data.manual_tags.push(searchTerm); + item.hasUnsavedChanges = true; + + // Add the tag element directly instead of re-rendering + const container = document.getElementById(`manualTagsContainer_${identifier}`); + if (container) { + const escapedTag = searchTerm.replace(/'/g, "\\'").replace(/"/g, '"'); + const newTag = document.createElement('span'); + newTag.className = 'tag manual'; + newTag.innerHTML = ` + ${searchTerm} + × + `; + const wrapper = container.querySelector('.tag-input-wrapper'); + container.insertBefore(newTag, wrapper); + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${identifier}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + } + } + } + } + } else if (event.key === 'Escape') { + // Close dropdown on escape + const dropdown = document.getElementById(`tagDropdownList_${identifier}`); + dropdown.classList.remove('show'); + event.target.blur(); + } +} + +// Close dropdown when clicking outside +document.addEventListener('click', function(event) { + if (!event.target.closest('.custom-dropdown')) { + document.querySelectorAll('.dropdown-list').forEach(dl => { + dl.classList.remove('show'); + }); + } +}); + +function toggleSubdocumentDisplayMode(path, isChecked) { + const subdoc = getSubdocByPath(path); + if (subdoc) { + // Set display_mode: AI_ONLY if checked, VISIBLE if unchecked + subdoc.display_mode = isChecked ? 'ai_only' : 'visible'; + subdoc.hasUnsavedChanges = true; + + // Update UI to show the state + const checkbox = document.getElementById(`displayModeCheckbox_${path}`); + const label = document.getElementById(`displayModeLabel_${path}`); + + if (checkbox && label) { + if (isChecked) { + label.style.opacity = '0.6'; + label.style.textDecoration = 'none'; + } else { + label.style.opacity = '1'; + label.style.textDecoration = 'none'; + } + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + + console.log(`Display mode for ${path} set to:`, subdoc.display_mode); + } +} + +function renderSubdocumentItemRecursive(subdoc, path, baseParentIndex) { + const subdocId = `subdoc_${path}`; + + // Initialize subdoc data structures if missing + if (!subdoc.media_type) subdoc.media_type = 'text/plain'; + if (!subdoc.description) subdoc.description = subdoc.summary || ''; + if (!subdoc.key_values) subdoc.key_values = []; + if (!subdoc.manual_tags) subdoc.manual_tags = []; + if (!subdoc.auto_tags) { + if (subdoc.tags && Array.isArray(subdoc.tags)) { + subdoc.auto_tags = subdoc.tags.map(tag => { + if (typeof tag === 'object' && tag.text) { + return tag.text; + } else if (typeof tag === 'string') { + return tag; + } + return ''; + }).filter(tag => tag); + } else { + subdoc.auto_tags = []; + } + } + + // Initialize display_mode if not set + if (!subdoc.display_mode) { + subdoc.display_mode = 'visible'; + } + + // Check if subdocument was manually saved + const saveButtonText = (subdoc.manuallySaved && !subdoc.hasUnsavedChanges) ? '✓ Saved' : 'Save changes'; + const saveButtonClass = (subdoc.manuallySaved && !subdoc.hasUnsavedChanges) ? 'btn btn-save saved' : 'btn btn-save'; + + // Generate tags HTML + const manualTagsHtml = subdoc.manual_tags.map(tag => { + let tagText = typeof tag === 'object' && tag.text ? tag.text : tag; + const escapedTagText = tagText.replace(/'/g, "\\'").replace(/"/g, '"'); + + return ` + + ${tagText} + × + + `; + }).join(''); + + const autoTagsHtml = subdoc.auto_tags.map(tag => { + let tagText = ''; + if (typeof tag === 'object' && tag.text) { + tagText = tag.text; + } else if (typeof tag === 'string') { + tagText = tag; + } + const escapedTagText = tagText.replace(/'/g, "\\'").replace(/"/g, '"'); + + return ` + + ${tagText} + × + + `; + }).join(''); + + // Generate key-values HTML with document type dropdown support + const kvHtml = updateKeyValueHtmlWithDocTypeDropdown(subdoc, path); + + // Generate images HTML + let imagesHtml = ''; + if (subdoc.images && subdoc.images.length > 0) { + const imagesGridHtml = subdoc.images.map((img, imgIndex) => { + let imgSrc = img.base64; + if (imgSrc && !imgSrc.startsWith('data:')) { + if (imgSrc.match(/^[A-Za-z0-9+/]+=*$/)) { + imgSrc = `data:image/${img.format || 'png'};base64,${imgSrc}`; + } + } + + return ` +
      + Image ${imgIndex + 1} +
      + Image failed to load +
      +
      + ${img.page ? `
      Page ${img.page}
      ` : ''} + ${img.width && img.height ? `
      ${img.width}x${img.height}
      ` : ''} +
      + +
      + `; + }).join(''); + + imagesHtml = ` +
      +
      Images (${subdoc.images.length})
      +
      + ${imagesGridHtml} +
      +
      + `; + } + + // Recursively render nested subdocuments + let nestedSubdocsHtml = ''; + if (subdoc.subdocument && Array.isArray(subdoc.subdocument) && subdoc.subdocument.length > 0) { + nestedSubdocsHtml = ` +
      +
      + Nested Subdocuments + (${subdoc.subdocument.length}) +
      + ${renderNestedSubdocuments(subdoc.subdocument, path, baseParentIndex)} +
      + `; + } + + const depth = path.split('_').length - 1; + let title = subdoc.title || `Subdocument Level ${depth}`; + + // Add special handling for link subdocuments + if (subdoc.document_type === 'linked_document' && subdoc.url && subdoc.url.length > 0) { + title = `📎 ${subdoc.url[0]}`; + } + + const isAiOnly = subdoc.display_mode === 'ai_only'; + + // Check if subdocument is an Excel file by extension or media type + const excelMediaTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/vnd.google-apps.spreadsheet' + ]; + const isSubdocExcelByType = subdoc.media_type && excelMediaTypes.includes(subdoc.media_type); + const isSubdocExcelByUrl = (subdoc.file_url && (subdoc.file_url.toLowerCase().endsWith('.xlsx') || subdoc.file_url.toLowerCase().endsWith('.xls'))) || + (subdoc.url && subdoc.url.length > 0 && (subdoc.url[0].toLowerCase().endsWith('.xlsx') || subdoc.url[0].toLowerCase().endsWith('.xls'))); + const isSubdocExcel = isSubdocExcelByType || isSubdocExcelByUrl; + + const subdocMarkdownHtml = isSubdocExcel ? ` +
      + + +
      + ` : ''; + + return ` +
      +
      +
      + ${subdoc.document_type === 'linked_document' ? '🔗 ' : ''}${title} +
      +
      +
      + +
      + + + + +
      +
      +
      + ${subdoc.document_type === 'linked_document' && subdoc.url ? ` +
      + + +
      + ${subdoc.source_document ? ` +
      + +
      + ${subdoc.source_document} +
      +
      + ` : ''} + ` : ''} + +
      + + +
      + +
      + + +
      + + ${subdocMarkdownHtml} + +
      + +
      +
      +

      Manual Tags

      +
      + ${manualTagsHtml} +
      +
      + + +
      +
      +
      +
      +
      +

      Auto Tags

      +
      + ${autoTagsHtml} +
      +
      +
      +
      + +
      + +
      + ${kvHtml} +
      + +
      + + ${imagesHtml} + ${nestedSubdocsHtml} + +
      + +
      +
      +
      + `; +} + +// Add these helper functions to handle path-based operations +function toggleSubdocumentByPath(path) { + const subdocId = `subdoc_${path}`; + const header = document.querySelector(`#${subdocId} .subdocument-header`); + const content = document.querySelector(`#${subdocId} .subdocument-content`); + + if (!header || !content) return; + + if (header.classList.contains('expanded')) { + header.classList.remove('expanded'); + content.classList.remove('show'); + expandedSubdocumentPaths.delete(path); // Remove from tracking + } else { + // Close any previously expanded subdocument at the same level + const currentLevel = path.split('_').length; + document.querySelectorAll('.subdocument-header.expanded').forEach(h => { + const otherId = h.parentElement.id; + const otherPath = otherId.replace('subdoc_', ''); + const otherLevel = otherPath.split('_').length; + if (otherLevel === currentLevel) { + h.classList.remove('expanded'); + h.nextElementSibling.classList.remove('show'); + expandedSubdocumentPaths.delete(otherPath); // Remove from tracking + } + }); + + header.classList.add('expanded'); + content.classList.add('show'); + expandedSubdocumentPaths.add(path); // Add to tracking + } +} + +function restoreExpandedStates() { + expandedSubdocumentPaths.forEach(path => { + const subdocId = `subdoc_${path}`; + const header = document.querySelector(`#${subdocId} .subdocument-header`); + const content = document.querySelector(`#${subdocId} .subdocument-content`); + + if (header && content) { + header.classList.add('expanded'); + content.classList.add('show'); + } + }); +} + +// Helper function to get subdocument by path +function getSubdocByPath(path) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (!item) return null; + + const pathParts = path.split('_'); + let current = item.data; + + for (let i = 1; i < pathParts.length; i++) { + const index = parseInt(pathParts[i]); + if (current.subdocument && current.subdocument[index]) { + current = current.subdocument[index]; + } else { + return null; + } + } + + return current; +} + +// Path-based save field function +function saveFieldByPath(path, field, value) { + const subdoc = getSubdocByPath(path); + if (subdoc) { + subdoc[field] = value; + subdoc.hasUnsavedChanges = true; + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } +} + +// Path-based save key-value function +function saveKeyValueByPath(path, kvIndex, field, value) { + const subdoc = getSubdocByPath(path); + if (subdoc && subdoc.key_values && subdoc.key_values[kvIndex]) { + const keyName = subdoc.key_values[kvIndex].key; + + // Process the value based on whether it should be an array or string + let processedValue = value; + if (field === 'value') { + const targetItem = { data: subdoc }; + + if (shouldPreserveAsArray(value, keyName, targetItem)) { + // Keep as formatted string for now, will be converted to array in collectFormData + processedValue = value; + } else { + // Process as regular formatted content + processedValue = processFormattedContentEnhanced(value, true, targetItem, keyName); + } + } else { + processedValue = value; + } + + subdoc.key_values[kvIndex][field] = processedValue; + subdoc.hasUnsavedChanges = true; + + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } +} + +// Path-based remove tag function +function removeTagByPath(path, tag, tagType) { + const subdoc = getSubdocByPath(path); + if (subdoc) { + const tagArray = tagType === 'manual' ? 'manual_tags' : 'auto_tags'; + if (subdoc[tagArray]) { + const tagIndex = subdoc[tagArray].indexOf(tag); + if (tagIndex > -1) { + subdoc[tagArray].splice(tagIndex, 1); + subdoc.hasUnsavedChanges = true; + + // Instead of re-rendering, just remove the tag element + const container = document.getElementById(`${tagType}TagsContainer_${path}`); + if (container) { + const tags = container.querySelectorAll('.tag'); + tags.forEach(tagEl => { + const tagText = tagEl.textContent.trim().replace('×', '').trim(); + if (tagText === tag) { + tagEl.remove(); + } + }); + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + } + } +} + +// Path-based handle tag input +function handleTagInputByPath(event, path, tagType) { + if (event.key === 'Enter') { + event.preventDefault(); + const input = event.target; + const tag = input.value.trim(); + + const subdoc = getSubdocByPath(path); + if (subdoc && tag) { + const tagArray = tagType === 'manual' ? 'manual_tags' : 'auto_tags'; + if (!subdoc[tagArray]) { + subdoc[tagArray] = []; + } + + if (!subdoc[tagArray].includes(tag)) { + subdoc[tagArray].push(tag); + subdoc.hasUnsavedChanges = true; + + // Add the tag element directly instead of re-rendering + const container = document.getElementById(`${tagType}TagsContainer_${path}`); + if (container) { + const newTag = document.createElement('span'); + newTag.className = `tag ${tagType}`; + newTag.innerHTML = ` + ${tag} + × + `; + container.insertBefore(newTag, input); + input.value = ''; + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + } + } +} + +// Path-based remove key-value function +function removeKeyValueByPath(path, kvIndex) { + if (!confirm('Are you sure you want to remove this section?')) { + return; + } + const subdoc = getSubdocByPath(path); + if (subdoc && subdoc.key_values) { + // Ensure we're removing the correct index + if (kvIndex >= 0 && kvIndex < subdoc.key_values.length) { + subdoc.key_values.splice(kvIndex, 1); + subdoc.hasUnsavedChanges = true; + + // Update DOM directly instead of re-rendering + const kvContainer = document.getElementById(`kvContainer_${path}`); + if (kvContainer) { + const kvPairs = kvContainer.querySelectorAll('.key-value-pair'); + if (kvPairs[kvIndex]) { + kvPairs[kvIndex].remove(); + + // Re-index remaining key-value pairs + const remainingPairs = kvContainer.querySelectorAll('.key-value-pair'); + remainingPairs.forEach((pair, newIndex) => { + // Update IDs and event handlers + const keyInput = pair.querySelector('input[type="text"]'); + const valueTextarea = pair.querySelector('textarea'); + const removeBtn = pair.querySelector('.remove-kv-btn'); + + if (keyInput) { + keyInput.id = `key_${path}_${newIndex}`; + keyInput.setAttribute('onchange', `saveKeyValueByPath('${path}', ${newIndex}, 'key', this.value)`); + } + if (valueTextarea) { + valueTextarea.id = `value_${path}_${newIndex}`; + valueTextarea.setAttribute('onchange', `saveKeyValueByPath('${path}', ${newIndex}, 'value', this.value)`); + } + if (removeBtn) { + removeBtn.setAttribute('onclick', `removeKeyValueByPath('${path}', ${newIndex})`); + } + }); + } + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + } +} + +// Path-based add key-value function +function addKeyValueByPath(path) { + const subdoc = getSubdocByPath(path); + if (subdoc) { + if (!subdoc.key_values) { + subdoc.key_values = []; + } + + const newIndex = subdoc.key_values.length; + subdoc.key_values.push({ + key: '', + value: '', + source: 'user', // Mark as user-added + original_type: 'string' + }); + subdoc.hasUnsavedChanges = true; + + // Add to DOM directly + const kvContainer = document.getElementById(`kvContainer_${path}`); + if (kvContainer) { + const newKvPair = document.createElement('div'); + newKvPair.className = 'key-value-pair structured-content-kv'; + newKvPair.innerHTML = ` + + + + `; + kvContainer.appendChild(newKvPair); + } + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } +} + +// Path-based remove subdocument function +function removeSubdocumentByPath(path) { + if (!confirm('Are you sure you want to remove this subdocument?')) { + return; + } + const pathParts = path.split('_'); + if (pathParts.length < 2) return; + + // Get parent path and index + const childIndex = parseInt(pathParts[pathParts.length - 1]); + const parentPath = pathParts.slice(0, -1).join('_'); + + const parent = parentPath === pathParts[0] ? + getSubdocByPath(parentPath) : + getSubdocByPath(parentPath); + + if (parent && parent.subdocument) { + parent.subdocument.splice(childIndex, 1); + parent.hasUnsavedChanges = true; + renderExtractedData(); + } +} + +// Path-based remove image function +function removeImageByPath(path, imageIndex) { + if (!confirm('Are you sure you want to remove this image?')) { + return; + } + const subdoc = getSubdocByPath(path); + if (subdoc && subdoc.images) { + subdoc.images.splice(imageIndex, 1); + subdoc.hasUnsavedChanges = true; + renderExtractedData(); + } +} + +// Path-based save function +function saveByPath(path, baseParentIndex) { + const saveBtn = document.getElementById(`saveBtn_${path}`); + if (!saveBtn) return; + + const originalText = saveBtn.textContent; + const subdoc = getSubdocByPath(path); + + if (subdoc) { + // Collect data from form + subdoc.media_type = document.getElementById(`mediaType_${path}`).value; + subdoc.description = document.getElementById(`description_${path}`).value; + + // Save manual tags + subdoc.manual_tags = []; + const manualTagsContainer = document.getElementById(`manualTagsContainer_${path}`); + if (manualTagsContainer) { + const manualTags = manualTagsContainer.querySelectorAll('.tag.manual'); + manualTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) subdoc.manual_tags.push(tagText); + }); + } + + // Save auto tags + subdoc.auto_tags = []; + const autoTagsContainer = document.getElementById(`autoTagsContainer_${path}`); + if (autoTagsContainer) { + const autoTags = autoTagsContainer.querySelectorAll('.tag.auto'); + autoTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) subdoc.auto_tags.push(tagText); + }); + } + + // Save key-value pairs + subdoc.key_values = []; + const kvContainer = document.getElementById(`kvContainer_${path}`); + if (kvContainer) { + const kvPairs = kvContainer.querySelectorAll('.key-value-pair'); + kvPairs.forEach((pair) => { + const keyInput = pair.querySelector(`input[id^="key_${path}_"]`); + const valueTextarea = pair.querySelector(`textarea[id^="value_${path}_"]`); + if (keyInput && valueTextarea) { + const key = keyInput.value; + const value = valueTextarea.value; + if (key || value) { + subdoc.key_values.push({ key, value }); + } + } + }); + } + + // Mark as saved + subdoc.manuallySaved = true; + subdoc.savedAt = new Date().toISOString(); + subdoc.hasUnsavedChanges = false; + + // Update button + saveBtn.textContent = '✓ Saved'; + saveBtn.classList.add('saved'); + saveBtn.disabled = false; + + resetSaveButtonAfterTimeout(`saveBtn_${path}`, 3000); + + console.log(`Saved subdocument at path ${path}:`, subdoc); + } +} + +// Save all files at once +function saveAllFiles() { + // Save current page first + const currentIndex = (currentPage - 1) * itemsPerPage; + saveCurrentFile(currentIndex); + + // Mark all valid items as saved + extractedData.forEach((item, index) => { + if (item && item.status === FILE_STATUS.SUCCESS) { + item.manuallySaved = true; + item.savedAt = new Date().toISOString(); + } + }); + + showStatus('All valid files have been saved!', 'success'); + console.log('All files saved:', extractedData); +} + +// Manual save for current file +function saveCurrentFile(index, isSubdoc = false, parentIndex = null, subdocIndex = null) { + const saveBtn = isSubdoc ? + document.getElementById(`saveBtn_subdoc_${parentIndex}_${subdocIndex}`) : + document.getElementById(`saveBtn_${index}`); + if (!saveBtn) return; + + const originalText = saveBtn.textContent; + + // Find the actual item in extractedData + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + // Save subdocument data + const subdoc = item.data.subdocument[subdocIndex]; + subdoc.media_type = document.getElementById(`mediaType_subdoc_${parentIndex}_${subdocIndex}`).value; + subdoc.description = document.getElementById(`description_subdoc_${parentIndex}_${subdocIndex}`).value; + + // Save manual tags + subdoc.manual_tags = []; + const manualTagsContainer = document.getElementById(`manualTagsContainer_subdoc_${parentIndex}_${subdocIndex}`); + if (manualTagsContainer) { + const manualTags = manualTagsContainer.querySelectorAll('.tag.manual'); + manualTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) subdoc.manual_tags.push(tagText); + }); + } + + // Save auto tags + subdoc.auto_tags = []; + const autoTagsContainer = document.getElementById(`autoTagsContainer_subdoc_${parentIndex}_${subdocIndex}`); + if (autoTagsContainer) { + const autoTags = autoTagsContainer.querySelectorAll('.tag.auto'); + autoTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) subdoc.auto_tags.push(tagText); + }); + } + + // Save key-value pairs + subdoc.key_values = []; + const kvContainer = document.getElementById(`kvContainer_subdoc_${parentIndex}_${subdocIndex}`); + if (kvContainer) { + const kvPairs = kvContainer.querySelectorAll('.key-value-pair'); + kvPairs.forEach((pair) => { + const keyInput = pair.querySelector(`input[id^="key_subdoc_"]`); + const valueInput = pair.querySelector(`input[id^="value_subdoc_"]`); + if (keyInput && valueInput) { + const key = keyInput.value; + const value = valueInput.value; + if (key || value) { + subdoc.key_values.push({ key, value }); + } + } + }); + } + + // Mark subdocument as saved + subdoc.manuallySaved = true; + subdoc.savedAt = new Date().toISOString(); + subdoc.hasUnsavedChanges = false; + } else { + // Save main document data + item.data.media_type = document.getElementById(`mediaType_${index}`).value; + item.data.description = document.getElementById(`description_${index}`).value; + + // Save manual tags + item.data.manual_tags = []; + const manualTagsContainer = document.getElementById(`manualTagsContainer_${index}`); + if (manualTagsContainer) { + const manualTags = manualTagsContainer.querySelectorAll('.tag.manual'); + manualTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) item.data.manual_tags.push(tagText); + }); + } + + // Save auto tags + item.data.auto_tags = []; + const autoTagsContainer = document.getElementById(`autoTagsContainer_${index}`); + if (autoTagsContainer) { + const autoTags = autoTagsContainer.querySelectorAll('.tag.auto'); + autoTags.forEach(tag => { + const tagText = tag.textContent.trim().replace('×', '').trim(); + if (tagText) item.data.auto_tags.push(tagText); + }); + } + + // Save key-value pairs + item.data.key_values = []; + const kvContainer = document.getElementById(`kvContainer_${index}`); + if (kvContainer) { + const kvPairs = kvContainer.querySelectorAll('.key-value-pair'); + kvPairs.forEach((pair, kvIndex) => { + const keyInput = pair.querySelector(`input[id^="key_"]`); + const valueTextarea = pair.querySelector(`textarea[id^="value_"]`); // Changed to textarea + if (keyInput && valueTextarea) { + const key = keyInput.value; + const value = valueTextarea.value; + if (key || value) { + item.data.key_values.push({ key, value }); + } + } + }); + } + + // Mark this item as manually saved + item.manuallySaved = true; + item.savedAt = new Date().toISOString(); + item.hasUnsavedChanges = false; + } + + // Update button to show saved state + saveBtn.textContent = '✓ Saved'; + saveBtn.disabled = false; + + // Reset button after 3 seconds + setTimeout(() => { + saveBtn.textContent = originalText; + saveBtn.classList.remove('saved'); + }, 3000); + + resetSaveButtonAfterTimeout(isSubdoc ? `saveBtn_subdoc_${parentIndex}_${subdocIndex}` : `saveBtn_${index}`, 3000); + + console.log(`Manually saved ${isSubdoc ? 'subdocument' : 'file'} ${index}:`, item.data); + } +} + +function resetSaveButtonAfterTimeout(buttonId, delay = 3000) { + const saveBtn = document.getElementById(buttonId); + if (!saveBtn) return; + + // Clear any existing timeout for this button + if (saveBtn.resetTimeout) { + clearTimeout(saveBtn.resetTimeout); + } + + // Set new timeout + saveBtn.resetTimeout = setTimeout(() => { + saveBtn.textContent = 'Save current changes'; + saveBtn.classList.remove('saved'); + saveBtn.disabled = false; + }, delay); +} + +// Save field data with timer +function saveFieldData(index, field, value, isSubdoc = false, parentIndex = null, subdocIndex = null) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + // Update subdocument field + item.data.subdocument[subdocIndex][field] = value; + item.data.subdocument[subdocIndex].hasUnsavedChanges = true; + + // Reset save button state + const saveBtn = document.getElementById(`saveBtn_subdoc_${parentIndex}_${subdocIndex}`); + if (saveBtn) { + // Clear any existing timeout + if (saveBtn.resetTimeout) { + clearTimeout(saveBtn.resetTimeout); + } + saveBtn.textContent = 'Save current changes'; + saveBtn.classList.remove('saved'); + saveBtn.disabled = false; + } + } else if (!isSubdoc && item.data) { + // Update main document field + item.data[field] = value; + item.hasUnsavedChanges = true; + + // Reset save button state when changes are made + const saveBtn = document.getElementById(`saveBtn_${index}`); + if (saveBtn) { + // Clear any existing timeout + if (saveBtn.resetTimeout) { + clearTimeout(saveBtn.resetTimeout); + } + saveBtn.textContent = 'Save current changes'; + saveBtn.classList.remove('saved'); + saveBtn.disabled = false; + } + } + + console.log(`Saved ${field} for ${isSubdoc ? 'subdocument' : 'item'} ${index}:`, value); + } +} + +// Save key-value data +function saveKeyValueData(index, kvIndex, field, value, isSubdoc = false, parentIndex = null, subdocIndex = null) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData && targetData.key_values && targetData.key_values[kvIndex]) { + const keyName = targetData.key_values[kvIndex].key; + + // Process the value based on whether it should be an array or string + let processedValue = value; + if (field === 'value') { + const targetItem = isSubdoc ? { data: targetData } : item; + + if (shouldPreserveAsArray(value, keyName, targetItem)) { + // Keep as formatted string for now, will be converted to array in collectFormData + processedValue = value; + } else { + // Process as regular formatted content + processedValue = processFormattedContentEnhanced(value, true, targetItem, keyName); + } + } else { + processedValue = value; + } + + targetData.key_values[kvIndex][field] = processedValue; + targetData.hasUnsavedChanges = true; + + // Reset save button state + const saveBtn = isSubdoc ? + document.getElementById(`saveBtn_subdoc_${parentIndex}_${subdocIndex}`) : + document.getElementById(`saveBtn_${index}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save changes'; + saveBtn.classList.remove('saved'); + } + } + + console.log(`Saved ${field} for ${isSubdoc ? 'subdoc' : 'item'} ${index}, kv ${kvIndex}:`, processedValue); + } +} + +// Subdocument functions +function toggleSubdocument(parentIndex, subdocIndex) { + const subdocId = `subdoc_${parentIndex}_${subdocIndex}`; + const header = document.querySelector(`#${subdocId} .subdocument-header`); + const content = document.querySelector(`#${subdocId} .subdocument-content`); + + if (!header || !content) return; + + // If this subdocument is already expanded, close it + if (header.classList.contains('expanded')) { + header.classList.remove('expanded'); + content.classList.remove('show'); + expandedSubdocument = null; + } else { + // Close any previously expanded subdocument + if (expandedSubdocument) { + const prevHeader = document.querySelector(`#${expandedSubdocument} .subdocument-header`); + const prevContent = document.querySelector(`#${expandedSubdocument} .subdocument-content`); + if (prevHeader) prevHeader.classList.remove('expanded'); + if (prevContent) prevContent.classList.remove('show'); + } + + // Expand this subdocument + header.classList.add('expanded'); + content.classList.add('show'); + expandedSubdocument = subdocId; + } +} + +function removeSubdocument(parentIndex, subdocIndex) { + if (!confirm('Are you sure you want to remove this subdocument?')) { + return; + } + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item && item.data && item.data.subdocument) { + item.data.subdocument.splice(subdocIndex, 1); + item.hasUnsavedChanges = true; + renderExtractedData(); + } +} + +// Image functions +function openImageModal(base64) { + const modal = document.getElementById('imageModal'); + const modalImg = document.getElementById('modalImage'); + modal.style.display = "block"; + modalImg.src = base64; +} + +function closeImageModal() { + const modal = document.getElementById('imageModal'); + modal.style.display = "none"; +} + +// Click outside modal to close +window.onclick = function(event) { + const modal = document.getElementById('imageModal'); + if (event.target == modal) { + closeImageModal(); + } +} + +function removeImage(parentIndex, imageIndex, isSubdoc = false, subdocIndex = null) { + if (!confirm('Are you sure you want to remove this image?')) { + return; + } + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData && targetData.images) { + targetData.images.splice(imageIndex, 1); + targetData.hasUnsavedChanges = true; + renderExtractedData(); + } + } +} + +// Pagination functions with bottom controls update +function updatePaginationControls() { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const totalValidItems = validItems.length; + + // Update top controls + document.getElementById('currentItemIndex').textContent = totalValidItems > 0 ? currentPage : 0; + document.getElementById('totalItems').textContent = totalValidItems; + + // Update bottom controls + document.getElementById('currentItemIndexBottom').textContent = totalValidItems > 0 ? currentPage : 0; + document.getElementById('totalItemsBottom').textContent = totalValidItems; + + // Update page numbers - only show current page + const pageNumbersContainer = document.getElementById('pageNumbers'); + const pageNumbersContainerBottom = document.getElementById('pageNumbersBottom'); + + pageNumbersContainer.innerHTML = ''; + pageNumbersContainerBottom.innerHTML = ''; + + if (totalValidItems === 0) { + // Update button states for no items + ['firstPageBtn', 'prevPageBtn', 'nextPageBtn', 'lastPageBtn', + 'firstPageBtnBottom', 'prevPageBtnBottom', 'nextPageBtnBottom', 'lastPageBtnBottom'].forEach(id => { + document.getElementById(id).disabled = true; + }); + + document.getElementById('pageJumpInput').max = 0; + document.getElementById('pageJumpInput').value = 0; + document.getElementById('pageJumpInputBottom').max = 0; + document.getElementById('pageJumpInputBottom').value = 0; + return; + } + + // Only show current page number + const pageBtn = document.createElement('button'); + pageBtn.className = 'page-btn active'; + pageBtn.textContent = currentPage; + pageBtn.disabled = true; + pageNumbersContainer.appendChild(pageBtn); + + const pageBtnBottom = pageBtn.cloneNode(true); + pageNumbersContainerBottom.appendChild(pageBtnBottom); + + // Update button states + document.getElementById('firstPageBtn').disabled = currentPage === 1; + document.getElementById('prevPageBtn').disabled = currentPage === 1; + document.getElementById('nextPageBtn').disabled = currentPage === totalPages; + document.getElementById('lastPageBtn').disabled = currentPage === totalPages; + + document.getElementById('firstPageBtnBottom').disabled = currentPage === 1; + document.getElementById('prevPageBtnBottom').disabled = currentPage === 1; + document.getElementById('nextPageBtnBottom').disabled = currentPage === totalPages; + document.getElementById('lastPageBtnBottom').disabled = currentPage === totalPages; + + // Update page jump inputs + document.getElementById('pageJumpInput').max = totalPages; + document.getElementById('pageJumpInput').value = currentPage; + document.getElementById('pageJumpInputBottom').max = totalPages; + document.getElementById('pageJumpInputBottom').value = currentPage; +} + +function resetAllSaveButtonStates() { + // Find all save buttons and reset their states + document.querySelectorAll('[id^="saveBtn_"]').forEach(btn => { + // Clear any existing timeout + if (btn.resetTimeout) { + clearTimeout(btn.resetTimeout); + } + + // Reset button appearance + btn.textContent = 'Save current changes'; + btn.classList.remove('saved'); + btn.disabled = false; + }); + + // Also reset any "hasUnsavedChanges" flags in the data + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + validItems.forEach(item => { + if (item.hasUnsavedChanges !== undefined) { + // Don't change the flag, just ensure buttons reflect current state + } + + // Check subdocuments recursively + function resetSubdocFlags(subdocs) { + if (subdocs && Array.isArray(subdocs)) { + subdocs.forEach(subdoc => { + if (subdoc.hasUnsavedChanges !== undefined) { + // Don't change the flag + } + if (subdoc.subdocument) { + resetSubdocFlags(subdoc.subdocument); + } + }); + } + } + + if (item.data && item.data.subdocument) { + resetSubdocFlags(item.data.subdocument); + } + }); +} + +function goToPage(page) { + if (page >= 1 && page <= totalPages && page !== currentPage) { + // Reset all save button states before changing page + resetAllSaveButtonStates(); + + expandedSubdocument = null; // Reset expanded subdocument when changing pages + expandedSubdocumentPaths.clear(); // Clear all expanded subdocument tracking + currentPage = page; + renderExtractedData(); + } +} + +function goToPrevPage() { + if (currentPage > 1) { + goToPage(currentPage - 1); + } +} + +function goToNextPage() { + if (currentPage < totalPages) { + goToPage(currentPage + 1); + } +} + +function jumpToPage() { + const pageInput = document.getElementById('pageJumpInput'); + const page = parseInt(pageInput.value); + if (!isNaN(page)) { + goToPage(page); + } +} + +function jumpToPageBottom() { + const pageInput = document.getElementById('pageJumpInputBottom'); + const page = parseInt(pageInput.value); + if (!isNaN(page)) { + goToPage(page); + } +} + +// Helper function to render subdocument item +function renderSubdocumentItem(subdoc, parentIndex, subdocIndex) { + const subdocId = `subdoc_${parentIndex}_${subdocIndex}`; + + // Initialize subdoc data structures if missing + if (!subdoc.media_type) subdoc.media_type = 'text/plain'; + if (!subdoc.description) subdoc.description = subdoc.summary || ''; + if (!subdoc.manual_tags) subdoc.manual_tags = []; + if (!subdoc.auto_tags) { + // Process tags from subdoc.tags if available + if (subdoc.tags && Array.isArray(subdoc.tags)) { + subdoc.auto_tags = subdoc.tags.map(tag => { + if (typeof tag === 'object' && tag.text) { + return tag.text; + } else if (typeof tag === 'string') { + return tag; + } + return ''; + }).filter(tag => tag); + } else { + subdoc.auto_tags = []; + } + } + if (!subdoc.key_values) subdoc.key_values = []; + + // Check if subdocument was manually saved + const saveButtonText = (subdoc.manuallySaved && !subdoc.hasUnsavedChanges) ? '✓ Saved' : 'Save changes'; + const saveButtonClass = (subdoc.manuallySaved && !subdoc.hasUnsavedChanges) ? 'btn btn-save saved' : 'btn btn-save'; + + // Generate tags HTML + const manualTagsHtml = subdoc.manual_tags.map(tag => { + let tagText = typeof tag === 'object' && tag.text ? tag.text : tag; + const escapedTagText = tagText.replace(/'/g, "\\'").replace(/"/g, '"'); + + return ` + + ${tagText} + × + + `; + }).join(''); + + const autoTagsHtml = subdoc.auto_tags.map(tag => { + // Extract tag text properly + let tagText = ''; + if (typeof tag === 'object' && tag.text) { + tagText = tag.text; + } else if (typeof tag === 'string') { + tagText = tag; + } + + // Escape single quotes in tag text for onclick handler + const escapedTagText = tagText.replace(/'/g, "\\'").replace(/"/g, '"'); + + + return ` + + ${tagText} + × + + `; + }).join(''); + + // Generate key-values HTML + const kvHtml = subdoc.key_values.map((kv, kvIndex) => ` +
      + + + +
      + `).join(''); + + // Generate images HTML + let imagesHtml = ''; + if (subdoc.images && subdoc.images.length > 0) { + const imagesGridHtml = subdoc.images.map((img, imgIndex) => ` +
      + Image ${imgIndex + 1} +
      + ${img.page ? `
      Page ${img.page}
      ` : ''} + ${img.width && img.height ? `
      ${img.width}x${img.height}
      ` : ''} +
      + +
      + `).join(''); + + imagesHtml = ` +
      +
      Images (${subdoc.images.length})
      +
      + ${imagesGridHtml} +
      +
      + `; + } + + // Check if subdocument is an Excel file by extension or media type + const legacyExcelMediaTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/vnd.google-apps.spreadsheet' + ]; + const isLegacySubdocExcelByType = subdoc.media_type && legacyExcelMediaTypes.includes(subdoc.media_type); + const isLegacySubdocExcelByUrl = (subdoc.file_url && (subdoc.file_url.toLowerCase().endsWith('.xlsx') || subdoc.file_url.toLowerCase().endsWith('.xls'))) || + (subdoc.url && subdoc.url.length > 0 && (subdoc.url[0].toLowerCase().endsWith('.xlsx') || subdoc.url[0].toLowerCase().endsWith('.xls'))); + const isLegacySubdocExcel = isLegacySubdocExcelByType || isLegacySubdocExcelByUrl; + + const legacySubdocMarkdownHtml = isLegacySubdocExcel ? ` +
      + + +
      + ` : ''; + + return ` +
      +
      +
      + ${subdoc.title || `Subdocument ${subdocIndex + 1}`} +
      +
      + + + + +
      +
      +
      +
      + + +
      + +
      + + +
      + + ${legacySubdocMarkdownHtml} + +
      + +
      + ${kvHtml} +
      + +
      + + ${imagesHtml} + +
      + +
      +
      +
      + `; +} + +function processFormattedContent(value, isKeyValue = false) { + if (!value || typeof value !== 'string') { + return value; + } + + // Check if content has bullet points or numbered lists + const hasBullets = value.includes('•') || /^\s*[-*]\s+/m.test(value) || /^\s*\d+\.\s+/m.test(value); + const hasNewlines = value.includes('\n'); + + if (hasBullets && hasNewlines) { + // Split into lines and process + const lines = value.split('\n').map(line => line.trim()).filter(line => line.length > 0); + const processedLines = lines.map(line => { + // Remove bullet points and numbering + return line.replace(/^[•\-*]\s*/, '').replace(/^\d+\.\s*/, '').trim(); + }).filter(line => line.length > 0); + + if (isKeyValue && processedLines.length > 1) { + // For key-values with multiple items, format as bullet list + return processedLines.map(line => `• ${line}`).join('\n'); + } else if (processedLines.length > 1) { + // For other content, preserve as formatted list + return processedLines.map(line => `• ${line}`).join('\n'); + } + } + + return value; +} + +function autoResizeTextarea(textarea) { + if (!textarea) return; + + textarea.style.height = 'auto'; + + // Get the key name for this textarea + const keyInput = textarea.closest('.key-value-pair')?.querySelector('.kv-key-input'); + const keyName = keyInput ? keyInput.value : ''; + + // Check if content should be formatted as a list + const value = textarea.value; + + // Try to get the item context for better array detection + let itemContext = null; + const mediaItem = textarea.closest('.media-item'); + if (mediaItem) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + itemContext = validItems[actualIndex]; + } + + const shouldBeArray = shouldPreserveAsArray(value, keyName, itemContext); + const shouldBeFormatted = shouldBeArray || isFormattedListContent(value); + + if (shouldBeFormatted && !textarea.classList.contains('formatted-list')) { + textarea.classList.add('formatted-list'); + if (value.length > 200) { + textarea.classList.add('long-content'); + } + } else if (!shouldBeFormatted && textarea.classList.contains('formatted-list')) { + textarea.classList.remove('formatted-list', 'long-content'); + } + + // For formatted list content, ensure minimum height + if (textarea.classList.contains('formatted-list')) { + const minHeight = textarea.classList.contains('long-content') ? 120 : 80; + const scrollHeight = Math.max(textarea.scrollHeight, minHeight); + textarea.style.height = scrollHeight + 'px'; + } else { + textarea.style.height = (textarea.scrollHeight) + 'px'; + } +} + +function resizeAllTextareas() { + document.querySelectorAll('.key-value-textarea').forEach(textarea => { + autoResizeTextarea(textarea); + }); +} + +function addTagFromDropdown(itemIndex, tagValue, isSubdoc = false, subdocIndex = null) { + if (!tagValue) return; + + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData) { + if (!targetData.manual_tags) { + targetData.manual_tags = []; + } + + // Check if tag already exists + if (!targetData.manual_tags.includes(tagValue)) { + targetData.manual_tags.push(tagValue); + targetData.hasUnsavedChanges = true; + + // Reset save button state + const saveBtn = isSubdoc ? + document.getElementById(`saveBtn_subdoc_${itemIndex}_${subdocIndex}`) : + document.getElementById(`saveBtn_${itemIndex}`); + if (saveBtn && saveBtn.classList.contains('saved')) { + saveBtn.textContent = 'Save current changes'; + saveBtn.classList.remove('saved'); + } + + renderExtractedData(); + } + + // Reset dropdown + const dropdown = document.getElementById( + isSubdoc ? + `tagDropdown_subdoc_${itemIndex}_${subdocIndex}` : + `tagDropdown_${itemIndex}` + ); + if (dropdown) { + dropdown.value = ''; + } + } + } +} + +// Helper functions for structured content handling + +function isArrayField(key, item) { + // Check if this field was originally an array by looking at metadata + if (item.data && item.data.array_fields_metadata) { + return item.data.array_fields_metadata.includes(key); + } + + // Fallback: check key-value metadata + if (item.data && item.data.key_values) { + const kv = item.data.key_values.find(kvPair => kvPair.key === key); + return kv && kv.original_type === 'array'; + } + + return false; +} + +function convertBulletPointsToArray(text) { + /** + * Convert bullet-pointed text back to array + * Handles various bullet point formats: •, -, *, numbered lists + */ + if (!text || typeof text !== 'string') { + return []; + } + + const lines = text.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + const items = []; + + for (const line of lines) { + // Remove bullet points and numbering + let cleanLine = line + .replace(/^[•\-*]\s*/, '') // Remove •, -, * bullets + .replace(/^\d+\.\s*/, '') // Remove numbered lists (1., 2., etc.) + .replace(/^\([a-zA-Z0-9]+\)\s*/, '') // Remove lettered lists (a), (1), etc. + .trim(); + + if (cleanLine.length > 0) { + items.push(cleanLine); + } + } + + return items; +} + +function convertArrayToBulletPoints(array) { + /** + * Convert array to bullet-pointed text for display + */ + if (!Array.isArray(array)) { + return array; + } + + return array + .filter(item => item !== null && item !== undefined) + .map(item => `• ${String(item).trim()}`) + .join('\n'); +} + +function shouldPreserveAsArray(value, key, item) { + /** + * Determine if a field should be saved as an array based on: + * 1. Original type metadata + * 2. Content analysis (has bullet points) + * 3. Field name patterns + */ + + // Check metadata first + if (isArrayField(key, item)) { + return true; + } + + // Check if content looks like a list + if (typeof value === 'string' && value.trim()) { + const lines = value.split('\n').filter(line => line.trim().length > 0); + + // Multiple lines with bullet points + if (lines.length > 1) { + const bulletLines = lines.filter(line => + /^[•\-*]\s+/.test(line.trim()) || /^\d+\.\s+/.test(line.trim()) + ); + + // If majority of lines have bullets, treat as array + if (bulletLines.length / lines.length >= 0.7) { + return true; + } + } + } + + // Common field names that are typically arrays + const arrayFieldPatterns = [ + /COMPONENTS?$/i, + /STEPS?$/i, + /ITEMS?$/i, + /LIST$/i, + /ELEMENTS?$/i, + /FACTORS?$/i, + /RISKS?$/i, + /LEARNINGS?$/i, + /CONTRIBUTORS?$/i, + /ASSETS?$/i, + /PREFERENCES?$/i, + /EVIDENCE$/i, + /THEORY.*CHANGE$/i, + /IMPLEMENTATION.*MODEL$/i + ]; + + return arrayFieldPatterns.some(pattern => pattern.test(key)); +} + +function processStructuredContentForSave(keyValues, item) { + /** + * Process key-values for saving, converting between arrays and strings as needed + */ + return keyValues.map(kv => { + const key = kv.key; + const value = kv.value; + + if (shouldPreserveAsArray(value, key, item)) { + // Convert bullet points back to array + if (typeof value === 'string') { + const arrayValue = convertBulletPointsToArray(value); + return { + key: key, + value: arrayValue, + original_type: 'array' + }; + } + } else { + // Keep as string, but clean up formatting + let cleanValue = value; + if (typeof value === 'string') { + // Preserve intentional paragraph breaks + cleanValue = value + .split('\n') + .map(line => line.trim()) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); // Limit to double line breaks + } + + return { + key: key, + value: cleanValue, + original_type: 'string' + }; + } + + return kv; + }); +} + +// Enhanced version of existing processFormattedContent function +function processFormattedContentEnhanced(value, isKeyValue = false, item = null, key = null) { + if (!value || typeof value !== 'string') { + return value; + } + + // If this should be preserved as an array, don't format as bullet points + if (item && key && shouldPreserveAsArray(value, key, item)) { + return value; // Keep as-is for array processing later + } + + // Check if content has bullet points or numbered lists + const hasBullets = value.includes('•') || /^\s*[-*]\s+/m.test(value) || /^\s*\d+\.\s+/m.test(value); + const hasNewlines = value.includes('\n'); + + if (hasBullets && hasNewlines) { + // Split into lines and process + const lines = value.split('\n').map(line => line.trim()).filter(line => line.length > 0); + const processedLines = lines.map(line => { + // Remove bullet points and numbering + return line.replace(/^[•\-*]\s*/, '').replace(/^\d+\.\s*/, '').trim(); + }).filter(line => line.length > 0); + + if (isKeyValue && processedLines.length > 1) { + // For key-values with multiple items, format as bullet list + return processedLines.map(line => `• ${line}`).join('\n'); + } else if (processedLines.length > 1) { + // For other content, preserve as formatted list + return processedLines.map(line => `• ${line}`).join('\n'); + } + } + + return value; +} + +// ============================================ +// SECTION 4: STEP 2 - REVIEW FUNCTIONS +// ============================================ +// Data rendering +function renderExtractedData() { + const container = document.getElementById('extractedDataContainer'); + container.innerHTML = ''; + + // Only show successful uploads + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + totalPages = Math.ceil(validItems.length / itemsPerPage); + + if (validItems.length === 0) { + updatePaginationControls(); + return; + } + + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, validItems.length); + + for (let pageIndex = startIndex; pageIndex < endIndex; pageIndex++) { + const item = validItems[pageIndex]; + const displayIndex = pageIndex; + const mediaItem = document.createElement('div'); + mediaItem.className = 'media-item active'; + + // Check if this item was manually saved and has no unsaved changes + const isSaved = item.manuallySaved && !item.hasUnsavedChanges; + const saveButtonText = isSaved ? '✓ Saved' : 'Save current changes'; + const saveButtonClass = isSaved ? 'btn btn-save saved' : 'btn btn-save'; + + // Auto-tags content + const autoTagsContent = (item.data.auto_tags || []).map(tag => { + const escapedTag = tag.replace(/'/g, "\\'").replace(/"/g, '"'); + return ` + + ${tag} + × + + `; + }).join(''); + + // Subdocuments content with informational message + let subdocsHtml = ''; + if (item.data.subdocument && item.data.subdocument.length > 0) { + subdocsHtml = ` +
      +
      + Subdocuments + (${item.data.subdocument.length}) +
      +
      +
      + + + + + + Information: These linked files will also be uploaded. Please review them for correctness of metadata. +
      +
      + ${renderNestedSubdocuments(item.data.subdocument, String(displayIndex), displayIndex)} +
      + `; + } + + // Images content + let imagesHtml = ''; + if (item.data.images && item.data.images.length > 0) { + const imagesGridHtml = item.data.images.map((img, imgIndex) => ` +
      + Image ${imgIndex + 1} +
      + ${img.page ? `
      Page ${img.page}
      ` : ''} + ${img.width && img.height ? `
      ${img.width}x${img.height}
      ` : ''} +
      + +
      + `).join(''); + + imagesHtml = ` +
      +
      Images (${item.data.images.length})
      +
      + ${imagesGridHtml} +
      +
      + `; + } + + // Generate key-values HTML with document type dropdown support for main document + const keyValuesHtml = updateMainDocumentKeyValueHtml(item, displayIndex); + + // Check if file is Excel to show Markdown Content field + const isExcelFile = item.filename && (item.filename.toLowerCase().endsWith('.xlsx') || item.filename.toLowerCase().endsWith('.xls')); + const markdownContentHtml = isExcelFile ? ` +
      + + +
      + ` : ''; + + mediaItem.innerHTML = ` +
      +
      + ${item.filename} + ${item.manuallySaved ? '(Saved at ' + new Date(item.savedAt).toLocaleTimeString() + ')' : ''} +
      +
      + + +
      +
      + +
      + + +
      + +
      + + +
      + + ${markdownContentHtml} + +
      + +
      +
      +

      Manual Tags

      +
      + ${(item.data.manual_tags || []).map(tag => { + const escapedTag = tag.replace(/'/g, "\\'").replace(/"/g, '"'); + return ` + + ${tag} + × + + `; + }).join('')} + +
      +
      + + +
      +
      +
      +
      +
      +

      Auto Tags

      +
      + ${autoTagsContent} +
      +
      +
      +
      + +
      + +
      + ${keyValuesHtml} +
      + +
      + + ${subdocsHtml} + ${imagesHtml} + `; + container.appendChild(mediaItem); + } + + updatePaginationControls(); + + // Reset all save button timeouts after rendering + setTimeout(() => { + resizeAllTextareas(); + restoreExpandedStates(); + resetAllSaveButtonStates(); + }, 100); +} + +function removeExtractedItem(validIndex) { + if (!confirm('Are you sure you want to remove this file? This will remove it completely from the review.')) { + return; + } + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const itemToRemove = validItems[validIndex]; + + if (itemToRemove) { + // Find and remove from extractedData + const actualIndex = extractedData.findIndex(item => item.id === itemToRemove.id); + if (actualIndex >= 0) { + extractedData.splice(actualIndex, 1); + } + + // Find and remove from uploadedFiles + const fileIndex = uploadedFiles.findIndex(f => f.name === itemToRemove.filename); + if (fileIndex >= 0) { + uploadedFiles.splice(fileIndex, 1); + } + } + + // Recalculate pagination + const remainingValidItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + totalPages = Math.ceil(remainingValidItems.length / itemsPerPage); + + if (currentPage > totalPages && totalPages > 0) { + currentPage = totalPages; + } + + renderExtractedData(); + + if (remainingValidItems.length === 0) { + updateStepIndicator(1); + updateFileList(); + } +} + +function removeTag(itemIndex, tag, tagType, isSubdoc = false, subdocIndex = null) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData) { + const tagArray = tagType === 'manual' ? 'manual_tags' : 'auto_tags'; + if (targetData[tagArray]) { + const tagIndex = targetData[tagArray].indexOf(tag); + if (tagIndex > -1) { + targetData[tagArray].splice(tagIndex, 1); + targetData.hasUnsavedChanges = true; + renderExtractedData(); + } + } + } + } +} + +function handleTagInput(event, itemIndex, tagType, isSubdoc = false, subdocIndex = null) { + if (event.key === 'Enter') { + event.preventDefault(); + const input = event.target; + const tag = input.value.trim(); + + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item && tag) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData) { + const tagArray = tagType === 'manual' ? 'manual_tags' : 'auto_tags'; + if (!targetData[tagArray]) { + targetData[tagArray] = []; + } + + if (!targetData[tagArray].includes(tag)) { + targetData[tagArray].push(tag); + targetData.hasUnsavedChanges = true; + renderExtractedData(); + } + } + } + } +} + +function removeKeyValue(itemIndex, kvIndex, isSubdoc = false, subdocIndex = null) { + if (!confirm('Are you sure you want to remove this section?')) { + return; + } + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData && targetData.key_values) { + targetData.key_values.splice(kvIndex, 1); + targetData.hasUnsavedChanges = true; + renderExtractedData(); + } + } +} + +function addKeyValue(itemIndex, isSubdoc = false, subdocIndex = null) { + const validItems = extractedData.filter(item => item.status === FILE_STATUS.SUCCESS); + const actualIndex = (currentPage - 1) * itemsPerPage; + const item = validItems[actualIndex]; + + if (item) { + let targetData; + if (isSubdoc && item.data.subdocument && item.data.subdocument[subdocIndex]) { + targetData = item.data.subdocument[subdocIndex]; + } else if (!isSubdoc && item.data) { + targetData = item.data; + } + + if (targetData) { + if (!targetData.key_values) { + targetData.key_values = []; + } + targetData.key_values.push({ + key: '', + value: '', + source: 'user', // Mark as user-added + original_type: 'string' + }); + targetData.hasUnsavedChanges = true; + renderExtractedData(); + } + } +} + +// Collect form data before saving +function collectFormData() { + extractedData.forEach((item) => { + if (item && item.status === FILE_STATUS.SUCCESS && item.data) { + // Get the selected organization for saving to Media model FK + const selectedOrgSlug = selectedOrganization ? selectedOrganization.slug : null; + const selectedOrgName = selectedOrganization ? selectedOrganization.name : (userCompanyName || ''); + + // Store organization info for Media model FK + item.data.organization_slug = selectedOrgSlug; + item.data.organization = selectedOrgName; + + // Remove ORGANIZATION from key_values as it will be saved to Media FK + if (item.data.key_values) { + item.data.key_values = item.data.key_values.filter(kv => kv.key !== 'ORGANIZATION'); + item.data.key_values = processStructuredContentForSave(item.data.key_values, item); + } + + // Combine manual and auto tags for saving + if (item.data.auto_tags_full) { + item.data.auto_tags = item.data.auto_tags_full; + } + item.data.tags = [...(item.data.manual_tags || []), ...(item.data.auto_tags || [])]; + + // Process subdocuments recursively + function processSubdocs(subdocs, parentOrgSlug, parentOrgName) { + if (subdocs && Array.isArray(subdocs)) { + subdocs.forEach(subdoc => { + // Set organization info for subdocument + subdoc.organization_slug = parentOrgSlug; + subdoc.organization = parentOrgName; + + // Process subdocument key-values and remove ORGANIZATION + if (subdoc.key_values) { + subdoc.key_values = subdoc.key_values.filter(kv => kv.key !== 'ORGANIZATION'); + subdoc.key_values = processStructuredContentForSave(subdoc.key_values, { data: subdoc }); + } + + // Combine tags for subdocuments + subdoc.tags = [...(subdoc.manual_tags || []), ...(subdoc.auto_tags || [])]; + + // Process nested subdocuments + if (subdoc.subdocument) { + processSubdocs(subdoc.subdocument, parentOrgSlug, parentOrgName); + } + }); + } + } + + processSubdocs(item.data.subdocument, selectedOrgSlug, selectedOrgName); + } + }); +} + +async function trackVectorDbBatchProgress(vectorDbTasks, totalFiles) { + return new Promise((resolve) => { + let completedTasks = 0; + const taskStatuses = {}; + + const checkProgress = async () => { + // Use existing endpoint to check multiple tasks + for (const task of vectorDbTasks) { + if (taskStatuses[task.task_id] === 'completed') continue; + + try { + const response = await fetch("{% url 'admin:chatbot_media_vector_db_task_status' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ task_id: task.task_id }) + }); + + const result = await response.json(); + if (result.success && result.ready) { + if (taskStatuses[task.task_id] !== 'completed') { + taskStatuses[task.task_id] = 'completed'; + completedTasks++; + } + } + } catch (error) { + console.error(`Error checking task ${task.task_id}:`, error); + } + } + + // Calculate progress + const dbSavedFiles = totalFiles - vectorDbTasks.length; // Files without vector DB tasks + const vectorDbProgress = completedTasks; + const totalProgress = dbSavedFiles + vectorDbProgress; + const percentage = Math.round((totalProgress / totalFiles) * 100); + + // Show enhanced progress using existing data + const currentFile = vectorDbTasks[completedTasks]?.filename || 'Processing...'; + showLoading(`Saving files... ${totalProgress}/${totalFiles} (${percentage}%) +
      Database: ✓ Complete +
      Vector DB: ${vectorDbProgress}/${vectorDbTasks.length} complete +
      Current: ${currentFile}`); + + // Check if all done + if (completedTasks >= vectorDbTasks.length) { + showLoading(`All files saved successfully! ${totalFiles}/${totalFiles} complete`); + setTimeout(resolve, 1000); // Brief delay to show completion + return; + } + + // Continue polling + setTimeout(checkProgress, 2000); // Every 2 seconds + }; + + // Start tracking + checkProgress(); + + // Safety timeout + setTimeout(resolve, 600000); // 10 minutes max + }); +} + +// ============================================ +// SECTION 5: STEP 3 - SAVE FUNCTIONS +// ============================================ +// Database operations +async function saveToDatabase(data) { + const companyBotId = document.getElementById('companyBotSelect').value; + // Only save successful items + const validItems = data.filter(item => item.status === FILE_STATUS.SUCCESS); + const totalItems = validItems.length; + + const items = validItems.map(item => ({ + ...item.data, + filename: item.filename, + file_index: item.file_index, + manual_tags: item.data.manual_tags || [], + auto_tags: item.data.auto_tags || [], + file_key: item.data.file_key, + session_id: item.data.session_id || sessionId, + subdocument: item.data.subdocument || [], + source_documents: item.data.source_documents || [], + images: item.data.images || [] + })); + + showLoading(`Saving ${totalItems} files to database...`); + + const response = await fetch("{% url 'admin:chatbot_media_batch_save' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ + company_bot_id: companyBotId, + items: items, + session_id: sessionId + }) + }); + + const result = await response.json(); + if (result.success) { + + const vectorDbTasks = result.results.filter(r => r.success && r.vector_task_id).map(r => ({ + task_id: r.vector_task_id, + filename: r.filename + })); + + // If there are vector DB tasks, show enhanced progress + if (vectorDbTasks.length > 0) { + await trackVectorDbBatchProgress(vectorDbTasks, totalItems); + } + + return result.results.map((res, index) => { + // If save failed, ensure file_key is preserved + if (!res.success && items[index]) { + res.file_key = res.file_key || items[index].file_key; + res.session_id = res.session_id || items[index].session_id; + } + return { + ...res, + originalData: items[index] + }; + }); + + } else { + throw new Error(result.error || 'Failed to save data'); + } +} + +// Retry save for individual item +async function retrySave(resultIndex) { + const result = saveResults[resultIndex]; + showLoading(`Retrying save for ${result.filename}...`); + + try { + let itemData; + + // First try to use originalData if available + if (result.originalData) { + itemData = result.originalData; + } else { + // Fallback: Find the original item data + const originalItem = extractedData.find(item => + item.filename === result.filename && item.status === FILE_STATUS.SUCCESS + ); + + if (!originalItem) { + throw new Error('Original item data not found'); + } + + itemData = { + ...originalItem.data, + filename: originalItem.filename, + file_index: originalItem.file_index, + manual_tags: originalItem.data.manual_tags || [], + auto_tags: originalItem.data.auto_tags || [], + file_key: originalItem.data.file_key, + session_id: originalItem.data.session_id || sessionId, + subdocument: originalItem.data.subdocument || [], + source_documents: originalItem.data.source_documents || [], + images: originalItem.data.images || [] + }; + } + + if (!result.partial_success) { + // Main document failed, so don't process subdocuments + itemData.subdocument = []; + } + + console.log('Retrying with data:', itemData); // Debug log + + const response = await fetch("{% url 'admin:chatbot_media_retry_save' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ + item_data: itemData, + company_bot_id: document.getElementById('companyBotSelect').value, + session_id: sessionId + }) + }); + + const retryResult = await response.json(); + if (retryResult.success) { + // Update the result + saveResults[resultIndex] = { + ...retryResult.result, + originalData: itemData // Preserve for potential future retries + }; + displayResults(saveResults); + showStatus(`Successfully retried save for ${result.filename}`, 'success'); + } else { + showStatus(`Retry failed for ${result.filename}: ${retryResult.error}`, 'error'); + } + } catch (error) { + showStatus(`Retry failed for ${result.filename}: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +async function saveAnywayResult(resultIndex) { + const result = saveResults[resultIndex]; + showLoading(`Saving ${result.filename} (bypassing similarity)...`); + + try { + const itemData = result.originalData || { + ...result, + bypass_similarity: true + }; + itemData.bypass_similarity = true; + + const response = await fetch("{% url 'admin:chatbot_media_retry_save' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ + item_data: itemData, + company_bot_id: document.getElementById('companyBotSelect').value, + session_id: sessionId, + bypass_similarity: true + }) + }); + + const retryResult = await response.json(); + if (retryResult.success) { + saveResults[resultIndex] = { + ...retryResult.result, + originalData: itemData + }; + displayResults(saveResults); + showStatus(`Successfully saved ${result.filename} (similarity check bypassed)`, 'success'); + } else { + showStatus(`Save failed for ${result.filename}: ${retryResult.error}`, 'error'); + } + } catch (error) { + showStatus(`Save failed for ${result.filename}: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +// Global variable to store save results for retry functionality +let saveResults = []; + +// Save button handler +document.getElementById('saveBtn').addEventListener('click', async () => { + collectFormData(); + showLoading('Saving to database...'); + + try { + const results = await saveToDatabase(extractedData); + saveResults = results; + displayResults(results); + updateStepIndicator(3); + } catch (error) { + showStatus('Error saving data: ' + error.message, 'error'); + } finally { + hideLoading(); + } +}); + +function renderSubdocumentResults(subdocResults, parentIndex, depth = 0) { + let html = '
      '; + + subdocResults.forEach((subdoc, subdocIndex) => { + const retryButton = !subdoc.success ? + `` : ''; + + html += ` +
      +
      + ${subdoc.title} + ${subdoc.success ? + `✓ Saved` : + `✗ ${subdoc.error || 'Failed'}` + } +
      + ${retryButton} +
      + `; + + // Render nested subdocuments recursively + if (subdoc.nested_subdocument_results && subdoc.nested_subdocument_results.length > 0) { + html += renderSubdocumentResults(subdoc.nested_subdocument_results, parentIndex, depth + 1); + } + }); + + html += '
      '; + return html; +} + +async function retrySubdocSave(parentIndex, path, cacheKey) { + const result = saveResults[parentIndex]; + + // Get subdocument data from cache + const cachedData = await getCachedSubdocument(cacheKey); + if (!cachedData || !cachedData.data) { + showStatus('Subdocument data not found in cache', 'error'); + return; + } + + showLoading(`Retrying subdocument save...`); + + try { + const response = await fetch("{% url 'admin:chatbot_media_retry_save' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ + item_data: cachedData.data, // Use the subdocument data from cache + company_bot_id: document.getElementById('companyBotSelect').value, + session_id: sessionId, + is_subdocument: true, + parent_media_id: result.media_id + }) + }); + + const retryResult = await response.json(); + if (retryResult.success && retryResult.result) { + // Update the subdocument result + updateSubdocumentResult(parentIndex, path, retryResult.result); + displayResults(saveResults); + showStatus('Subdocument saved successfully', 'success'); + } else { + showStatus(`Subdocument retry failed: ${retryResult.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + showStatus(`Error retrying subdocument save: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +function updateSubdocumentResult(parentIndex, path, newResult) { + if (!saveResults[parentIndex]) return; + + function updateResultRecursive(results, targetPath, newResult) { + for (let i = 0; i < results.length; i++) { + if (results[i].path === targetPath) { + results[i] = { ...results[i], ...newResult }; + return true; + } + if (results[i].nested_subdocument_results) { + if (updateResultRecursive(results[i].nested_subdocument_results, targetPath, newResult)) { + return true; + } + } + } + return false; + } + + if (saveResults[parentIndex].subdocument_results) { + updateResultRecursive(saveResults[parentIndex].subdocument_results, path, newResult); + } +} + +async function getCachedSubdocument(cacheKey) { + try { + const response = await fetch("{% url 'admin:chatbot_media_get_cached_item' %}", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + body: JSON.stringify({ cache_key: cacheKey }) + }); + + const result = await response.json(); + if (result.success && result.data) { + return result.data; // Return just the data part + } + return null; + } catch (error) { + console.error('Error retrieving cached subdocument:', error); + return null; + } +} + +function toggleSubdocResults(index) { + const container = document.getElementById(`subdoc-results-${index}`); + const arrow = event.currentTarget; + + if (container.classList.contains('expanded')) { + container.classList.remove('expanded'); + arrow.classList.remove('expanded'); + } else { + container.classList.add('expanded'); + arrow.classList.add('expanded'); + } +} + +// Display save results +function displayResults(results) { + const successCount = results.filter(r => r.success).length; + const failedCount = results.filter(r => !r.success).length; + + let totalSubdocs = 0; + let failedSubdocs = 0; + + document.getElementById('totalFiles').textContent = uploadedFiles.length; + document.getElementById('finalSuccessCount').textContent = successCount; + document.getElementById('failedCount').textContent = failedCount; + + // Show/hide retry all button + const retryAllBtn = document.getElementById('retryAllBtn'); + if (failedCount > 0) { + retryAllBtn.style.display = 'inline-block'; + retryAllBtn.textContent = `Retry All Failed Saves (${failedCount})`; + } else { + retryAllBtn.style.display = 'none'; + } + + const resultsList = document.getElementById('resultsList'); + resultsList.innerHTML = ''; + + // In displayResults function, update the results.forEach section: + results.forEach((result, index) => { + // Calculate subdocument stats + let subdocStats = { total: 0, success: 0, failed: 0 }; + + function countSubdocs(subdocResults) { + subdocResults.forEach(subdoc => { + subdocStats.total++; + if (subdoc.success) { + subdocStats.success++; + } else { + subdocStats.failed++; + } + if (subdoc.nested_subdocument_results) { + countSubdocs(subdoc.nested_subdocument_results); + } + }); + } + + if (result.subdocument_results) { + countSubdocs(result.subdocument_results); + } + + const resultItem = document.createElement('div'); + resultItem.className = `save-result-item ${result.success ? 'success' : 'error'}`; + + const hasSubdocs = result.subdocument_results && result.subdocument_results.length > 0; + const subdocToggle = hasSubdocs ? + ` + + ` : ''; + + const retryButton = !result.success ? + `` : ''; + + // Add subdoc stats display + const subdocStatsHtml = hasSubdocs ? ` +
      + + + ${subdocStats.success} + + + + ${subdocStats.failed} + +
      + ` : ''; + + resultItem.innerHTML = ` +
      ${subdocToggle}${result.success ? '✓' : '✗'}
      +
      +

      ${result.filename}

      +

      ${result.message}

      + ${result.success && result.media_id ? `

      Media ID: ${result.media_id}

      ` : ''} + ${hasSubdocs ? ` +

      Subdocuments: ${subdocStats.success}/${subdocStats.total} saved ${subdocStatsHtml}

      + ` : ''} + ${result.image_results && result.image_results.length > 0 ? ` +

      Images: ${result.image_results.filter(i => i.success).length}/${result.image_results.length} saved

      + ` : ''} +
      +
      + ${retryButton} +
      + `; + resultsList.appendChild(resultItem); + + // Add subdocument results if any + if (hasSubdocs) { + const subdocContainer = document.createElement('div'); + subdocContainer.className = 'subdoc-results'; + subdocContainer.id = `subdoc-results-${index}`; + subdocContainer.innerHTML = renderSubdocumentResults(result.subdocument_results, index); + resultsList.appendChild(subdocContainer); + } + }); + + // Update the total failed count to include subdocuments + let totalFailedSubdocs = 0; + results.forEach(result => { + function countFailedSubdocs(subdocResults) { + subdocResults.forEach(subdoc => { + if (!subdoc.success) totalFailedSubdocs++; + if (subdoc.nested_subdocument_results) { + countFailedSubdocs(subdoc.nested_subdocument_results); + } + }); + } + if (result.subdocument_results) { + countFailedSubdocs(result.subdocument_results); + } + }); + + document.getElementById('failedCount').textContent = failedCount + totalFailedSubdocs; + + // Add skipped files + const skippedItems = extractedData.filter(item => item.status === FILE_STATUS.SKIPPED); + skippedItems.forEach(item => { + const resultItem = document.createElement('div'); + resultItem.className = 'save-result-item'; + resultItem.style.borderLeftColor = '#ff9800'; + resultItem.style.backgroundColor = '#fff8e1'; + + resultItem.innerHTML = ` +
      +
      +

      ${item.filename}

      +

      File was skipped during upload

      +
      + `; + resultsList.appendChild(resultItem); + }); + + if (successCount > 0) { + showStatus(`Successfully saved ${successCount} file(s)!`, 'success'); + } + if (failedCount > 0) { + showStatus(`${failedCount} file(s) failed to save. You can retry individual files.`, 'warning'); + } +} + +// Clear polling on page unload +window.addEventListener('beforeunload', () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } +}); + +// Initialize +updateFileList(); \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/steps/step1_upload.html b/chatbot/templates/admin/batch_upload/steps/step1_upload.html new file mode 100644 index 0000000..ac2c352 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/steps/step1_upload.html @@ -0,0 +1,142 @@ +

      Upload Files

      + + +
      + + +
      + This organization will be used as metadata for all uploaded files. +
      +
      + + + + + +
      + + +
      + +
      +
      + + + + + +
      +

      Template File Handling

      +

      + By default, files containing the keyword "template" in the title will be treated as templatized files that contain user-defined key-value pairs and an embedded link to the solution/asset. You can disable this default behavior by removing the "template" reference from the upload document. +

      +
      +
      +
      + +
      + + + + + +

      Drag and drop files here or click to browse

      +

      + Supported formats: + {% for file_type in file_types %}{{ file_type.label }}{% if not forloop.last %}, {% endif %}{% endfor %} +

      + +
      + + +
      +
      +
      + Upload Progress + +
      +
      +
      + + 0 +
      +
      + + 0 +
      +
      +
      +
      +
      +
      +
      +
      Starting upload...
      +
      +
      + + +
      + + + + + +
      + +
      + Back to Media List +
      + +
      +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/steps/step2_review.html b/chatbot/templates/admin/batch_upload/steps/step2_review.html new file mode 100644 index 0000000..6ed53e6 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/steps/step2_review.html @@ -0,0 +1,20 @@ +

      Review Data

      + + +{% include 'admin/batch_upload/components/pagination_controls.html' with position='top' %} + +
      + + +
      + × + +
      + + +{% include 'admin/batch_upload/components/pagination_controls.html' with position='bottom' %} + +
      +
      + +
      \ No newline at end of file diff --git a/chatbot/templates/admin/batch_upload/steps/step3_save.html b/chatbot/templates/admin/batch_upload/steps/step3_save.html new file mode 100644 index 0000000..5570829 --- /dev/null +++ b/chatbot/templates/admin/batch_upload/steps/step3_save.html @@ -0,0 +1,30 @@ +

      Save Results

      + +
      +
      +

      Total Files

      +
      0
      +
      +
      +

      Successful

      +
      0
      +
      +
      +

      Failed

      +
      0
      +
      +
      + + +
      + +
      + +
      + +
      +
      + Exit +
      \ No newline at end of file diff --git a/chatbot/templates/admin/change_display_mode.html b/chatbot/templates/admin/change_display_mode.html new file mode 100644 index 0000000..1191116 --- /dev/null +++ b/chatbot/templates/admin/change_display_mode.html @@ -0,0 +1,111 @@ +{# admin/change_display_mode.html #} +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block title %}{{ title }} | Django Site Admin{% endblock %} + +{% block content %} +
      +

      {{ title }}

      + +
      +

      You have selected {{ selected_count }} file(s).

      +
      + +
      + {% csrf_token %} + +
      +
      + + +
      +
      + +
      +
      + Apply to: +
      + +
      +
      + +
      +
      +
      + +
      + Cancel + +
      +
      + +
      + +

      Display Mode Descriptions:

      +
        +
      • Visible: File is visible to all users in the frontend
      • +
      • AI Only: File is hidden from the UI but available for AI processing (chatbot, MIP generation)
      • +
      • Private: File is hidden from both the UI and AI processing
      • +
      +
      +{% endblock %} + +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/chatbot/templates/admin/change_list.html b/chatbot/templates/admin/change_list.html new file mode 100644 index 0000000..ca7e2ce --- /dev/null +++ b/chatbot/templates/admin/change_list.html @@ -0,0 +1,56 @@ +{% extends "admin/change_list.html" %} +{% load i18n %} +{% load admin_urls static admin_list %} + +{% block object-tools-items %} +{{ block.super }} +{% if has_add_permission %} + +{# Upload button for batch upload #} +{% if has_batch_upload %} +
    1. + + {% trans 'Upload' %} + +
    2. +{% endif %} + +{# Upload button for Media model #} +{% if opts.model_name == 'media' %} +
    3. + + {% trans 'Add drive' %} + +
    4. +
    5. + + {% trans 'Upload media' %} + +
    6. +{% endif %} + +{# Post Processing button for Story model #} +{% if opts.model_name == 'story' and show_post_processing_button %} +
    7. + + {% trans 'Post Processing' %} + +
    8. +{% endif %} + +{# Export and Import buttons for CompanyBot model #} +{% if opts.model_name == 'companybot' %} +
    9. + + {% trans 'Export All Bots' %} + +
    10. +
    11. + + {% trans 'Import Bots' %} + +
    12. +{% endif %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/chatbot/templates/admin/export_format.html b/chatbot/templates/admin/export_format.html new file mode 100644 index 0000000..28db053 --- /dev/null +++ b/chatbot/templates/admin/export_format.html @@ -0,0 +1,137 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block title %}Export Company Bots{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
      +

      Export Company Bots

      +

      Select an export format and click “Download”.

      + +
      +

      You are about to export {{ bot_count }} bot{{ bot_count|pluralize }}.

      +
      + +
      + {% if selected_ids %} + + {% endif %} + +
      + + +
      + +
      + + Cancel +
      +
      +
      + + +{% endblock %} diff --git a/chatbot/templates/admin/export_story_format.html b/chatbot/templates/admin/export_story_format.html new file mode 100644 index 0000000..f82649f --- /dev/null +++ b/chatbot/templates/admin/export_story_format.html @@ -0,0 +1,27 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block content %} +
      +

      📤 Export Stories

      +
      {% csrf_token %} + + +
      + + +
      + +
      + +
      +
      +
      +{% endblock %} diff --git a/chatbot/templates/admin/filter.html b/chatbot/templates/admin/filter.html new file mode 100644 index 0000000..30d83b6 --- /dev/null +++ b/chatbot/templates/admin/filter.html @@ -0,0 +1,515 @@ +{% load i18n %} +{% block extrahead %} + + + + + + + + + + + + + +{% endblock %} + +
      + {% if title == "From Date" %} +
      + + + +
      + + {% else %} +
      + +
      + {% endif %} +
      + + + + + \ No newline at end of file diff --git a/chatbot/templates/admin/generic_batch_upload.html b/chatbot/templates/admin/generic_batch_upload.html new file mode 100644 index 0000000..faa8e4f --- /dev/null +++ b/chatbot/templates/admin/generic_batch_upload.html @@ -0,0 +1,1181 @@ +{% extends "admin/base_site.html" %} +{% load static i18n %} + +{% block title %}Batch Import - {{ model_name }}{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block content %} +
      +

      Batch Import: {{ model_verbose_name|default:"Records" }}

      + + +
      +
      +
      1
      + Configure & Upload +
      +
      +
      2
      + Review & Validate +
      +
      +
      3
      + Import Results +
      +
      + + +
      +

      Configure Import Settings

      + +
      +

      Model: {{ model_verbose_name }}

      +

      {{ model_description|default:"Select fields to import and upload your data file." }}

      +
      + + +
      +

      Select Fields to Import

      +
      + Note: Required fields are marked with * and cannot be unchecked. +
      + +
      + +
      + +
      + + + +
      +
      + + +
      + + + + + +

      Drag and drop your file here or click to browse

      +

      Supported formats: CSV, Excel (.xlsx, .xls)

      + +
      + +
      +

      File Selected:

      +

      +

      + +
      + +
      + Cancel + +
      +
      + + +
      +

      Review & Validate Data

      + +
      + +
      + + +
      +
      + + + +
      + + + +
      +
      + + +
      +

      Import Results

      + +
      +
      +

      Successful

      +
      0
      +
      +
      +

      Failed

      +
      0
      +
      +
      +

      Total Processed

      +
      0
      +
      +
      + +
      + +
      + +
      + + Back to List +
      +
      + + +
      +
      +
      +

      Processing...

      +
      +
      +
      + + + +{% endblock %} \ No newline at end of file diff --git a/chatbot/templates/admin/i18n_change_list.html b/chatbot/templates/admin/i18n_change_list.html new file mode 100644 index 0000000..a8e75e0 --- /dev/null +++ b/chatbot/templates/admin/i18n_change_list.html @@ -0,0 +1,21 @@ +{% extends "admin/change_list.html" %} {% load i18n admin_urls static admin_list +%} {% block object-tools-items %} {{ block.super }} +
    13. + + Upload + +
    14. +{% endblock %} diff --git a/chatbot/templates/admin/i18n_export.html b/chatbot/templates/admin/i18n_export.html new file mode 100644 index 0000000..0dd2b57 --- /dev/null +++ b/chatbot/templates/admin/i18n_export.html @@ -0,0 +1,148 @@ +{% extends "admin/base_site.html" %} {% load static %} {% block title %}Export +I18n Translations{% endblock %} {% block breadcrumbs %} + +{% endblock %} {% block content %} +
      +

      Export I18n Translations

      +

      Select a language to export all translations as JSON.

      + +
      + {% csrf_token %} + +
      + + +
      + +
      + + Cancel +
      +
      +
      + + +{% endblock %} diff --git a/chatbot/templates/admin/import_form.html b/chatbot/templates/admin/import_form.html new file mode 100644 index 0000000..4051592 --- /dev/null +++ b/chatbot/templates/admin/import_form.html @@ -0,0 +1,213 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block title %}Import Company Bots{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
      +

      Import Company Bots

      +

      Upload a file containing bot data in JSON, Excel, or CSV format.

      + +
      + {% csrf_token %} + +
      + + +

      + Supported formats: JSON, XLSX, or CSV
      + Include all related Voice and StateMachine records in the file. +

      +
      + +
      + + Cancel +
      +
      + +
      +

      📘 Import Instructions

      +
        +
      • JSON Format: Nested structure with voices and state_machines arrays.
      • +
      • XLSX Format: Use three sheets — Bots, Voices, and StateMachines.
      • +
      • CSV Format: Single sheet, flattened structure with JSON columns.
      • +
      + +

      📄 Download Templates

      + +
      +
      + + + + +{% endblock %} diff --git a/chatbot/templates/admin/post_processing/forms/unique_challenges_form.html b/chatbot/templates/admin/post_processing/forms/unique_challenges_form.html new file mode 100644 index 0000000..53813e5 --- /dev/null +++ b/chatbot/templates/admin/post_processing/forms/unique_challenges_form.html @@ -0,0 +1,91 @@ +
      +
      + + + How many parallel workers to use for processing. +
      + +
      + + + How many challenges to process together in each batch. +
      +
      + +
      +
      + + + The system will keep filtering duplicates until this many rounds. +
      + +
      + + + Stop processing when filtering is lower than this percentage. Lower percentage means aggressive filtering. +
      +
      + +
      + + + JSON file containing the data to process + +
      + + +
      + OR +
      + +
      + +
      +
      + + +
      +
      + + +
      +
      + Select a date range to fetch challenges from the database. Both start and end dates are included in the results. + +
      + +
      + + + +
      + + + + diff --git a/chatbot/templates/admin/post_processing/forms/unique_solutions_form.html b/chatbot/templates/admin/post_processing/forms/unique_solutions_form.html new file mode 100644 index 0000000..c267b53 --- /dev/null +++ b/chatbot/templates/admin/post_processing/forms/unique_solutions_form.html @@ -0,0 +1,91 @@ +
      +
      + + + How many parallel workers to use for processing. +
      + +
      + + + How many solutions to process together in each batch. +
      +
      + +
      +
      + + + The system will keep filtering duplicates until this many rounds. +
      + +
      + + + Stop processing when filtering is lower than this percentage. Lower percentage means aggressive filtering. +
      +
      + +
      + + + JSON file containing the data to process + +
      + + +
      + OR +
      + +
      + +
      +
      + + +
      +
      + + +
      +
      + Select a date range to fetch solutions from the database. Both start and end dates are included in the results. + +
      + +
      + + + +
      + + + + diff --git a/chatbot/templates/admin/post_processing/post_processing.html b/chatbot/templates/admin/post_processing/post_processing.html new file mode 100644 index 0000000..4eb66b0 --- /dev/null +++ b/chatbot/templates/admin/post_processing/post_processing.html @@ -0,0 +1,739 @@ +{% extends "admin/base_site.html" %} +{% load static i18n admin_urls %} + +{% block title %}Post Processing - {{ model_name|title }}{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
      +

      Post Processing

      + +
      +
      + + + + + +
      +

      Do Not Reload Page

      +

      + Reloading page will remove the current progress. +

      +
      +
      +
      + + + + +
      + + +{% endblock %} diff --git a/chatbot/templates/google_drive_integration.html b/chatbot/templates/google_drive_integration.html new file mode 100644 index 0000000..2cf172f --- /dev/null +++ b/chatbot/templates/google_drive_integration.html @@ -0,0 +1,437 @@ +{% extends 'admin/base_site.html' %} +{% load i18n static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + + +
      +
      +

      Batch Upload

      +
      + +
      +
      1
      Configure & Fetch
      +
      +
      2
      Processing & Saving
      +
      + +
      + +
      +

      Upload Files

      + +
      + + +
      This organization will be used as metadata for all fetched files.
      +
      + +
      + +
      + + +
      +
      + +
      + + +
      + + Back to Media List +
      + +
      +

      Processing & Saving

      + +
      +
      + + + +

      Connecting to Google Drive...

      +

      Verifying folder permissions and fetching documents.

      + + +
      + + +
      +
      +
      +
      + + + + + + + + +{% endblock %} diff --git a/chatbot/tests.py b/chatbot/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/chatbot/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/chatbot/translate/__init__.py b/chatbot/translate/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/chatbot/translate/ai4Bharat/__init__.py b/chatbot/translate/ai4Bharat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/translate/ai4Bharat/base_translation.py b/chatbot/translate/ai4Bharat/base_translation.py new file mode 100644 index 0000000..5cde680 --- /dev/null +++ b/chatbot/translate/ai4Bharat/base_translation.py @@ -0,0 +1,2873 @@ +import os +import requests + + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_pipeline_id = os.getenv("BHASHANI_PIPELINE_ID") + + +def get_ulca_pipeline_models(task_type, source_language=None, target_language=None): + url = "https://meity-auth.ulcacontrib.org/ulca/apis/v0/model/getModelsPipeline" + + payload = { + "pipelineTasks": [ + {"taskType": task_type}, + ], + "pipelineRequestConfig": { + "pipelineId": ai4bharat_pipeline_id + } + } + + headers = { + 'userID': ai4bharat_user_id, + 'ulcaApiKey': ai4bharat_api_key, + 'Content-Type': 'application/json' + } + print("payload: ", payload) + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + print("response: ", response.text) + response.raise_for_status() + + data = response.json() + service_id = None + + configs = data.get("pipelineResponseConfig", []) + for task in configs: + if task.get("taskType") == task_type: + for cfg in task.get("config", []): + lang_cfg = cfg.get("language", {}) + src = lang_cfg.get("sourceLanguage") + tgt = lang_cfg.get("targetLanguage") + + if source_language == src and (target_language is None or target_language == tgt): + service_id = cfg.get("serviceId") + break + if service_id: + break + api_key = data.get("pipelineInferenceAPIEndPoint", {}).get("inferenceApiKey", {}).get("value") + + return { + 'success': True, + 'service_id': service_id, + 'inference_api_key': api_key, + 'raw_data': data + } + except requests.exceptions.RequestException as e: + print("Get model pipeline error: ", e) + return { + 'success': False, + 'error': str(e), + 'service_id': None, + 'inference_api_key': None, + 'raw_data': None + } + + +def get_service_id(task_type, source_language=None, target_language=None): + service_id=None + + model_pipeline = { + "languages": [ + { + "sourceLanguage": "bn", + "targetLanguageList": [ + "en", + "as", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "en", + "targetLanguageList": [ + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "gu", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "hi", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "kn", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "ml", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "mr", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "or", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "pa", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "ta", + "te" + ] + }, + { + "sourceLanguage": "sa", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + }, + { + "sourceLanguage": "ta", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "te" + ] + }, + { + "sourceLanguage": "te", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta" + ] + }, + { + "sourceLanguage": "ur", + "targetLanguageList": [ + "en", + "as", + "bn", + "brx", + "gu", + "hi", + "kn", + "ml", + "mni", + "mr", + "or", + "pa", + "ta", + "te" + ] + } + ], + "pipelineResponseConfig": [ + { + "taskType": "asr", + "config": [ + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411746956e9de23f65b5426", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/whisper-medium-en--gpu--t4", + "modelId": "641c0be440abd176d64c3f92", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411746056e9de23f65b5425", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-hi-gpu--t4", + "modelId": "648025f27cdd753e77f461a9", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-dravidian-gpu--t4", + "modelId": "641174a356e9de23f65b5429", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-dravidian-gpu--t4", + "modelId": "6411749856e9de23f65b5428", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411744b56e9de23f65b5424", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "64117440b1463435d2fbaec3", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411743456e9de23f65b5423", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411742856e9de23f65b5422", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-dravidian-gpu--t4", + "modelId": "641174ad56e9de23f65b542a", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-dravidian-gpu--t4", + "modelId": "6411748db1463435d2fbaec5", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu" + }, + "domain": [ + "general" + ] + }, + { + "serviceId": "ai4bharat/conformer-multilingual-indo_aryan-gpu--t4", + "modelId": "6411741c56e9de23f65b5421", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran" + }, + "domain": [ + "general" + ] + } + ] + }, + { + "taskType": "translation", + "config": [ + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d3e8ecee6735a1b3793", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d238ecee6735a1b3778", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4c92a6a31751ff1f35", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dd192a6a31751ff1fb1", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1492a6a31751ff1f06", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d708ecee6735a1b37b9", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c8492a6a31751ff1e96", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1da68ecee6735a1b37e2", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c7a8ecee6735a1b36dd", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc18ecee6735a1b37f8", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1b92a6a31751ff1f0d", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d398ecee6735a1b378c", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce092a6a31751ff1ee5", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c838ecee6735a1b36ea", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cc98ecee6735a1b371e", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d818ecee6735a1b37c7", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c738ecee6735a1b36d6", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cae8ecee6735a1b370d", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d6592a6a31751ff1f49", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c788ecee6735a1b36db", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c6c8ecee6735a1b36d1", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1db892a6a31751ff1f97", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2a8ecee6735a1b377e", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d7c8ecee6735a1b37c3", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dd98ecee6735a1b380c", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c6c8ecee6735a1b36d2", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1caa92a6a31751ff1eb6", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cab8ecee6735a1b370b", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9392a6a31751ff1ea0", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cba92a6a31751ff1ec5", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9d92a6a31751ff1eab", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d248ecee6735a1b3779", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d548ecee6735a1b37a0", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9392a6a31751ff1f73", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4d92a6a31751ff1f37", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9c92a6a31751ff1ea9", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cb292a6a31751ff1ebe", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d378ecee6735a1b3789", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dd992a6a31751ff1fb8", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1caa8ecee6735a1b3709", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ddc92a6a31751ff1fbb", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cd192a6a31751ff1ed7", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cd18ecee6735a1b372a", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9b92a6a31751ff1f77", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dba8ecee6735a1b37f1", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c8b92a6a31751ff1e9a", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d7892a6a31751ff1f5a", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cb692a6a31751ff1ec2", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d6892a6a31751ff1f4d", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9892a6a31751ff1f76", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1e8ecee6735a1b3774", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dd78ecee6735a1b380b", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0692a6a31751ff1efd", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cc492a6a31751ff1ecf", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1db68ecee6735a1b37ef", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc692a6a31751ff1fa2", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d6392a6a31751ff1f48", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cb78ecee6735a1b3712", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c828ecee6735a1b36e8", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d798ecee6735a1b37c0", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c7092a6a31751ff1e89", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cc492a6a31751ff1ece", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9f92a6a31751ff1ead", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cbb92a6a31751ff1ec7", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cbb92a6a31751ff1ec6", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d378ecee6735a1b378a", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc08ecee6735a1b37f6", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cce92a6a31751ff1ed6", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cea92a6a31751ff1eeb", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cfd92a6a31751ff1ef8", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1392a6a31751ff1f05", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c988ecee6735a1b36fc", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d338ecee6735a1b3786", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1492a6a31751ff1f07", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1a8ecee6735a1b376f", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d5e8ecee6735a1b37a9", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0d92a6a31751ff1f02", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4892a6a31751ff1f30", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1db292a6a31751ff1f92", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2f8ecee6735a1b3782", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d968ecee6735a1b37d2", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d088ecee6735a1b375e", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cd792a6a31751ff1edd", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9692a6a31751ff1ea3", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cc792a6a31751ff1ed4", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1da592a6a31751ff1f83", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cbe92a6a31751ff1ec9", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0f8ecee6735a1b3765", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c7792a6a31751ff1e91", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d498ecee6735a1b379a", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce28ecee6735a1b3737", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cb78ecee6735a1b3713", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c8092a6a31751ff1e95", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2992a6a31751ff1f18", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce68ecee6735a1b373a", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cbf92a6a31751ff1eca", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc092a6a31751ff1f9f", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dce8ecee6735a1b3804", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d058ecee6735a1b375c", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d128ecee6735a1b3769", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c6f8ecee6735a1b36d4", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cc28ecee6735a1b371b", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d3292a6a31751ff1f20", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c8f8ecee6735a1b36f6", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1daf8ecee6735a1b37e7", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dde8ecee6735a1b3811", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4e8ecee6735a1b379b", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1db58ecee6735a1b37ec", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce592a6a31751ff1eea", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d3d8ecee6735a1b3792", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2692a6a31751ff1f16", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c858ecee6735a1b36eb", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d8392a6a31751ff1f63", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0e8ecee6735a1b3764", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0c92a6a31751ff1f01", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dbc8ecee6735a1b37f3", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cfb8ecee6735a1b3750", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce78ecee6735a1b373b", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1da88ecee6735a1b37e3", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9a8ecee6735a1b37da", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d8e92a6a31751ff1f6c", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dbc92a6a31751ff1f9b", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d278ecee6735a1b377c", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cfc8ecee6735a1b3752", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ddd8ecee6735a1b3810", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ca092a6a31751ff1eaf", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ca78ecee6735a1b3705", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ccd8ecee6735a1b3724", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ca492a6a31751ff1eb2", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d738ecee6735a1b37bc", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc492a6a31751ff1fa1", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d778ecee6735a1b37be", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d138ecee6735a1b376a", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d028ecee6735a1b3757", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cdf92a6a31751ff1ee4", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1de992a6a31751ff1fc5", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cee8ecee6735a1b3743", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c878ecee6735a1b36ed", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cad92a6a31751ff1eb7", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cdd92a6a31751ff1ee2", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d598ecee6735a1b37a5", + "language": { + "sourceLanguage": "sa", + "sourceScriptCode": "Deva", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2d92a6a31751ff1f1d", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9492a6a31751ff1ea1", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2992a6a31751ff1f19", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4d92a6a31751ff1f36", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d098ecee6735a1b3760", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c9b8ecee6735a1b36fe", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2892a6a31751ff1f17", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d358ecee6735a1b3788", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1de68ecee6735a1b3819", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dac92a6a31751ff1f8b", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ca68ecee6735a1b3703", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d3e92a6a31751ff1f26", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d698ecee6735a1b37b3", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1892a6a31751ff1f0a", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ca98ecee6735a1b3707", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1ce98ecee6735a1b373e", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d898ecee6735a1b37cc", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d178ecee6735a1b376c", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c7492a6a31751ff1e90", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d8b92a6a31751ff1f6a", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dcc8ecee6735a1b3802", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4b92a6a31751ff1f34", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d1d8ecee6735a1b3772", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d5d92a6a31751ff1f44", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cd08ecee6735a1b3728", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9e92a6a31751ff1f7c", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c818ecee6735a1b36e7", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cef92a6a31751ff1eee", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d9a8ecee6735a1b37d9", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "en", + "targetScriptCode": "Latn" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2492a6a31751ff1f14", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "as", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d4092a6a31751ff1f28", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "bn", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc28ecee6735a1b37fa", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "brx", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2592a6a31751ff1f15", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "gu", + "targetScriptCode": "Gujr" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1cd58ecee6735a1b372c", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "hi", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d508ecee6735a1b379d", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "kn", + "targetScriptCode": "Knda" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dc692a6a31751ff1fa4", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "ml", + "targetScriptCode": "Mlym" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d8d92a6a31751ff1f6b", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "mni", + "targetScriptCode": "Mtei" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d8092a6a31751ff1f60", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "mni", + "targetScriptCode": "Beng" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d2a92a6a31751ff1f1a", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "mr", + "targetScriptCode": "Deva" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d038ecee6735a1b3759", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "or", + "targetScriptCode": "Orya" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1dbb8ecee6735a1b37f2", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "pa", + "targetScriptCode": "Guru" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1c7392a6a31751ff1e8e", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "ta", + "targetScriptCode": "Taml" + } + }, + { + "serviceId": "ai4bharat/indictrans-v2-all-gpu--t4", + "modelId": "641d1d0392a6a31751ff1efc", + "language": { + "sourceLanguage": "ur", + "sourceScriptCode": "Aran", + "targetLanguage": "te", + "targetScriptCode": "Telu" + } + } + ] + }, + { + "taskType": "tts", + "config": [ + { + "serviceId": "ai4bharat/indic-tts-coqui-misc-gpu--t4", + "modelId": "63f7384c2ff3ab138f88c64e", + "language": { + "sourceLanguage": "en", + "sourceScriptCode": "Latn" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "6348db0bfd966563f61bc2c0", + "language": { + "sourceLanguage": "as", + "sourceScriptCode": "Beng" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-misc-gpu--t4", + "modelId": "636e60ebff7cd87a3f7e0ff4", + "language": { + "sourceLanguage": "brx", + "sourceScriptCode": "Deva" + }, + "supportedVoices": [ + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "636e60ef86369150cb00432b", + "language": { + "sourceLanguage": "gu", + "sourceScriptCode": "Gujr" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "633c021bfb796d5e100d4ff9", + "language": { + "sourceLanguage": "hi", + "sourceScriptCode": "Deva" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-dravidian-gpu--t4", + "modelId": "636e60f486369150cb00432c", + "language": { + "sourceLanguage": "kn", + "sourceScriptCode": "Knda" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-dravidian-gpu--t4", + "modelId": "636e60f986369150cb00432d", + "language": { + "sourceLanguage": "ml", + "sourceScriptCode": "Mlym" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-misc-gpu--t4", + "modelId": "648a8f561a0aff9b4805c032", + "language": { + "sourceLanguage": "mni", + "sourceScriptCode": "Mtei" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-misc-gpu--t4", + "modelId": "636e60feff7cd87a3f7e0ff5", + "language": { + "sourceLanguage": "mni", + "sourceScriptCode": "Beng" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "636e6103ff7cd87a3f7e0ff6", + "language": { + "sourceLanguage": "mr", + "sourceScriptCode": "Deva" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "6348db26fd966563f61bc2c2", + "language": { + "sourceLanguage": "or", + "sourceScriptCode": "Orya" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "63905c2bff7cd87a3f7e1022", + "language": { + "sourceLanguage": "pa", + "sourceScriptCode": "Guru" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-dravidian-gpu--t4", + "modelId": "6348db32fd966563f61bc2c3", + "language": { + "sourceLanguage": "ta", + "sourceScriptCode": "Taml" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-dravidian-gpu--t4", + "modelId": "6348db37fb796d5e100d4ffe", + "language": { + "sourceLanguage": "te", + "sourceScriptCode": "Telu" + }, + "supportedVoices": [ + "male", + "female" + ] + }, + { + "serviceId": "ai4bharat/indic-tts-coqui-indo_aryan-gpu--t4", + "modelId": "636e60e586369150cb00432a", + "language": { + "sourceLanguage": "bn", + "sourceScriptCode": "Beng" + }, + "supportedVoices": [ + "male", + "female" + ] + } + ] + }, + { + "taskType": "transliteration", + "config": [ + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b0426e74a1c96b489b5441", + "language": { + "sourceLanguage": "en", + "targetLanguage": "as" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c7c97d6da5111fca0f5e4", + "language": { + "sourceLanguage": "en", + "targetLanguage": "bn" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b0427878d51611abf708c4", + "language": { + "sourceLanguage": "en", + "targetLanguage": "brx" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b3c64fa65d5a242f462655", + "language": { + "sourceLanguage": "en", + "targetLanguage": "gom" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c7c7d2abd9b3200b3003b", + "language": { + "sourceLanguage": "en", + "targetLanguage": "gu" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c73ce41dcd012c08f07e3", + "language": { + "sourceLanguage": "en", + "targetLanguage": "hi" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c7e662abd9b3200b3003c", + "language": { + "sourceLanguage": "en", + "targetLanguage": "kn" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b0429574a1c96b489b5442", + "language": { + "sourceLanguage": "en", + "targetLanguage": "ks" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628cafad2abd9b3200b3003f", + "language": { + "sourceLanguage": "en", + "targetLanguage": "mai" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628ca83c2abd9b3200b3003e", + "language": { + "sourceLanguage": "en", + "targetLanguage": "ml" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b0429f78d51611abf708c5", + "language": { + "sourceLanguage": "en", + "targetLanguage": "mni" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c811dd6da5111fca0f5e5", + "language": { + "sourceLanguage": "en", + "targetLanguage": "mr" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b042a878d51611abf708c6", + "language": { + "sourceLanguage": "en", + "targetLanguage": "ne" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b042b878d51611abf708c7", + "language": { + "sourceLanguage": "en", + "targetLanguage": "or" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628ca0c52abd9b3200b3003d", + "language": { + "sourceLanguage": "en", + "targetLanguage": "pa" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "62b042c374a1c96b489b5443", + "language": { + "sourceLanguage": "en", + "targetLanguage": "sa" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628cab0ed6da5111fca0f5e8", + "language": { + "sourceLanguage": "en", + "targetLanguage": "sd" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628cad21d6da5111fca0f5e9", + "language": { + "sourceLanguage": "en", + "targetLanguage": "si" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628c741941dcd012c08f07e4", + "language": { + "sourceLanguage": "en", + "targetLanguage": "ta" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628ca307d6da5111fca0f5e6", + "language": { + "sourceLanguage": "en", + "targetLanguage": "te" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "628ca3e8d6da5111fca0f5e7", + "language": { + "sourceLanguage": "en", + "targetLanguage": "ur" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e599dd811234cfe86bb", + "language": { + "sourceLanguage": "as", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513eae9dd811234cfe86c3", + "language": { + "sourceLanguage": "bn", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e949dd811234cfe86c1", + "language": { + "sourceLanguage": "brx", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e3d9dd811234cfe86b8", + "language": { + "sourceLanguage": "gom", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513eb5610f2c0e43eeb476", + "language": { + "sourceLanguage": "gu", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513d39610f2c0e43eeb46e", + "language": { + "sourceLanguage": "hi", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e7d9dd811234cfe86c0", + "language": { + "sourceLanguage": "kn", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e4e610f2c0e43eeb471", + "language": { + "sourceLanguage": "ks", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e6c9dd811234cfe86be", + "language": { + "sourceLanguage": "mai", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e549dd811234cfe86ba", + "language": { + "sourceLanguage": "ml", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e84610f2c0e43eeb473", + "language": { + "sourceLanguage": "mni", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e71610f2c0e43eeb472", + "language": { + "sourceLanguage": "mr", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e5f9dd811234cfe86bc", + "language": { + "sourceLanguage": "ne", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e779dd811234cfe86bf", + "language": { + "sourceLanguage": "or", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e659dd811234cfe86bd", + "language": { + "sourceLanguage": "pa", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e43610f2c0e43eeb470", + "language": { + "sourceLanguage": "sa", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e489dd811234cfe86b9", + "language": { + "sourceLanguage": "sd", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e9d9dd811234cfe86c2", + "language": { + "sourceLanguage": "si", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e36610f2c0e43eeb46f", + "language": { + "sourceLanguage": "ta", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513ea6610f2c0e43eeb475", + "language": { + "sourceLanguage": "te", + "targetLanguage": "en" + } + }, + { + "serviceId": "ai4bharat/indicxlit--cpu-fsv2", + "modelId": "63513e8c610f2c0e43eeb474", + "language": { + "sourceLanguage": "ur", + "targetLanguage": "en" + } + } + ] + } + ], + "feedbackUrl": "https://dhruva-api.bhashini.gov.in/services/feedback/submit", + "pipelineInferenceAPIEndPoint": { + "callbackUrl": "https://dhruva-api.bhashini.gov.in/services/inference/pipeline", + "inferenceApiKey": { + "name": "Authorization", + "value": "z1l-1j40BTm3WrJBTmfty3jOYT0WHglffqCKNIbwqxvXx-tcfD5IWitrhmXaqj3z" + }, + "isMultilingualEnabled": True, + "isSyncApi": True + }, + "pipelineInferenceSocketEndPoint": { + "callbackUrl": "wss://dhruva-api.bhashini.gov.in", + "inferenceApiKey": { + "name": "Authorization", + "value": "z1l-1j40BTm3WrJBTmfty3jOYT0WHglffqCKNIbwqxvXx-tcfD5IWitrhmXaqj3z" + }, + "isMultilingualEnabled": True, + "isSyncApi": True + } + } + configs = model_pipeline.get("pipelineResponseConfig", []) + for task in configs: + if task.get("taskType") == task_type: + for cfg in task.get("config", []): + lang_cfg = cfg.get("language", {}) + src = lang_cfg.get("sourceLanguage") + tgt = lang_cfg.get("targetLanguage") + + if source_language == src and (target_language is None or target_language == tgt): + service_id = cfg.get("serviceId") + break + if service_id: + break + + if service_id and service_id != '': + return { + 'success': True, + 'service_id': service_id, + } + else: + return { + 'success': False, + 'service_id': None, + } \ No newline at end of file diff --git a/chatbot/translate/ai4Bharat/speech_to_text.py b/chatbot/translate/ai4Bharat/speech_to_text.py new file mode 100644 index 0000000..d051754 --- /dev/null +++ b/chatbot/translate/ai4Bharat/speech_to_text.py @@ -0,0 +1,133 @@ +import base64 +import os +import traceback +import requests +import json_repair +import concurrent.futures +import logging +from chatbot.translate.base.speech_to_text import split_audio + + +logger = logging.getLogger('django') +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_base_url = os.getenv("BHASHANI_BASE_URL") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_authorization = os.getenv("BHASHANI_AUTHORIZATION") + + +def transcribe_single_chunk(chunk_number, chunk, audio_format, source_language, voice_provider): + b64_chunk = base64.b64encode(chunk).decode('utf-8') + response = ai4bharat_speech_text( + base64=b64_chunk, + audio_format=audio_format, + source_language=source_language, + voice_provider=voice_provider + ) + logger.info(f"response: {response}") + if response['status'] == 200: + return (chunk_number, response['content']) + else: + return (chunk_number, '') + + +def transcribe_ai4bharat_multiple_chunks(voice_provider, base64_audio_file, source_language, audio_format): + try: + audio_bytes = base64.b64decode(base64_audio_file) + duration = 10 + if voice_provider.other_params: + duration = int(voice_provider.other_params.get('chunk_duration', 10)) + chunks = split_audio(audio_bytes, chunk_duration=duration) + + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + transcribe_single_chunk, chunk_number, chunk, audio_format, source_language, + voice_provider + ) + for chunk_number, chunk in chunks + ] + transcripts = [future.result() for future in concurrent.futures.as_completed(futures)] + transcripts.sort() + + transcript = " ".join(content for _, content in transcripts) + return {'status': 200, 'content': transcript} + + except Exception as e: + logger.error('Error processing file: %s', e, exc_info=True) + traceback.print_exc() + return {'status': 500, 'content': str(e)} + + + +def ai4bharat_speech_text(voice_provider, base64, audio_format, source_language): + try: + other_params = voice_provider.other_params if voice_provider.other_params else {} + + payload = { + "pipelineTasks": [ + { + "taskType": "asr", + "config": { + "language": { + "sourceLanguage": source_language, + }, + "serviceId": other_params.get('serviceId', "bhashini/iitm/asr-dravidian--gpu--t4"), + "audioFormat": audio_format, + "samplingRate": int(other_params.get('samplingRate', 16000)) if isinstance( + other_params.get('samplingRate'), int) or str( + other_params.get('samplingRate')).isdigit() else 16000, + "preProcessors": other_params.get('preProcessors') if isinstance( + other_params.get('preProcessors'), list) else [], + "postProcessors": other_params.get('postProcessors') if isinstance( + other_params.get('postProcessors'), list) else [], + + } + } + ], + "inputData": { + "audio": [ + { + "audioContent": base64 + } + ] + } + } + + headers = { + 'accept': '*/*', + 'content-type': 'application/json', + 'Authorization': ai4bharat_authorization, + 'userID': ai4bharat_user_id, + 'ulcaApiKey': ai4bharat_api_key + } + response = requests.post(ai4bharat_base_url, json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + print("response: ", response.text) + audio_data = json_repair.repair_json(response.text, return_objects=True) + if isinstance(audio_data, dict) and 'pipelineResponse' in audio_data: + audio_content = audio_data['pipelineResponse'][0].get('output', [{}])[0].get('source', '') + print("TRANSCRIPT: ", audio_content) + return { + 'status': 200, + 'content': audio_content + } + else: + return { + 'status': 500, + 'content': 'Unexpected response format from AI4Bharat API' + } + else: + print("Error in response: ", response.text) + return { + 'status': response.status_code, + 'content': 'Failed to fetch audio from AI4Bharat API' + } + + except Exception as e: + logger.error('Error processing file: %s', e, exc_info=True) + traceback.print_exc() + return { + 'status': 500, + 'content': str(e) + } diff --git a/chatbot/translate/ai4Bharat/text_lang_detect.py b/chatbot/translate/ai4Bharat/text_lang_detect.py new file mode 100644 index 0000000..164ca84 --- /dev/null +++ b/chatbot/translate/ai4Bharat/text_lang_detect.py @@ -0,0 +1,63 @@ +import os +import traceback +import requests + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_base_url = os.getenv("BHASHANI_BASE_URL") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_authorization = os.getenv("BHASHANI_AUTHORIZATION") + + +def call_ai4bharat_text_lang_detect_api(message_body): + api_url = ai4bharat_base_url + + payload = { + "pipelineTasks": [ + { + "taskType": "txt-lang-detection", + "config": { + "serviceId": "bhashini/iiiith/indic-lang-detection-all" + } + } + ], + "inputData": { + "input": [ + { + "source": message_body + } + ] + } +} + + headers = { + 'accept': '*/*', + 'content-type': 'application/json', + 'Authorization': ai4bharat_authorization, + } + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=10) + print("Response: ", response) + print("Res text: ", response.json()) + if response.status_code == 200: + lang_detect_data = response.json() + if isinstance(lang_detect_data, dict) and 'pipelineResponse' in lang_detect_data: + lang_detect_message = (lang_detect_data['pipelineResponse'][0].get('output', [{}])[0]. + get('langPrediction', [{}])[0].get('langCode', 'en')) + + print("lang_detect_message: ", lang_detect_message) + return { + 'status': 200, + 'content': lang_detect_message + } + return { + 'status': 200, + 'content': message_body + } + except Exception as e: + print(f"Error during language detect API call: {str(e)}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error during language detect API call: {str(e)}" + } diff --git a/chatbot/translate/ai4Bharat/text_to_speech.py b/chatbot/translate/ai4Bharat/text_to_speech.py new file mode 100644 index 0000000..7141fa1 --- /dev/null +++ b/chatbot/translate/ai4Bharat/text_to_speech.py @@ -0,0 +1,75 @@ +import os +import traceback +import requests + + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_base_url = os.getenv("BHASHANI_BASE_URL") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_authorization = os.getenv("BHASHANI_AUTHORIZATION") + + +def ai4bharat_text_speech(voice_provider, text, gender, source_language): + try: + + api_url = ai4bharat_base_url + print("gender: ", gender) + print("source_language: ", source_language) + print("original text: ", text) + + other_params = voice_provider.other_params if voice_provider.other_params else {} + + payload = { + "pipelineTasks": [ + { + "taskType": "tts", + "config": { + "language": { + "sourceLanguage": source_language, + }, + "gender": gender.lower(), + "serviceId": other_params.get('serviceId', 'Bhashini/IITM/TTS'), + "samplingRate": other_params.get('samplingRate', 22050), + } + } + ], + "inputData": { + "input": [ + { + "source": text + } + ] + } + } + + headers = { + 'accept': '*/*', + 'content-type': 'application/json', + 'Authorization': ai4bharat_authorization, + } + response = requests.post(api_url, json=payload, headers=headers, timeout=10) + if response.status_code == 200: + audio_data = response.json() + if isinstance(audio_data, dict) and 'pipelineResponse' in audio_data: + audio_content = audio_data['pipelineResponse'][0].get('audio', [{}])[0].get('audioContent', '') + return { + 'status': 200, + 'content': audio_content + } + else: + return { + 'status': 500, + 'content': 'Unexpected response format from AI4Bharat API' + } + else: + return { + 'status': response.status_code, + 'content': 'Failed to fetch audio from AI4Bharat API' + } + + except Exception as e: + traceback.print_exc() + return { + 'status': 500, + 'content': str(e) + } diff --git a/chatbot/translate/ai4Bharat/text_to_text.py b/chatbot/translate/ai4Bharat/text_to_text.py new file mode 100644 index 0000000..10f292d --- /dev/null +++ b/chatbot/translate/ai4Bharat/text_to_text.py @@ -0,0 +1,75 @@ +import os +import traceback +import requests +import logging + + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_base_url = os.getenv("BHASHANI_BASE_URL") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_authorization = os.getenv("BHASHANI_AUTHORIZATION") +logger = logging.getLogger('django') + + +def call_ai4bharat_translation_api(voice_provider, source_language, target_language, message_body): + api_url = ai4bharat_base_url + + other_params = voice_provider.other_params if voice_provider.other_params else {} + + payload = { + "pipelineTasks": [ + { + "taskType": "translation", + "config": { + "language": { + "sourceLanguage": source_language, + "targetLanguage": target_language, + }, + "serviceId": other_params.get('serviceId', 'bhashini/iiith/nmt-all'), + } + } + ], + "inputData": { + "input": [ + { + "source": message_body + } + ] + } + } + + headers = { + 'accept': '*/*', + 'content-type': 'application/json', + 'Authorization': ai4bharat_authorization, + } + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=60) + print("Response: ", response) + print("Res text: ", response.json()) + logger.info(f"Response from AI4Bharat Text Translation {response}") + logger.info(f"JSON Response from AI4Bharat Text Translation {response.json()}") + + if response.status_code == 200: + translated_data = response.json() + if isinstance(translated_data, dict) and 'pipelineResponse' in translated_data: + translated_message = translated_data['pipelineResponse'][0].get('output', [{}])[0].get('target', '') + + print("translated_message: ", translated_message) + return { + 'status': 200, + 'content': translated_message + } + return { + 'status': 200, + 'content': message_body + } + except Exception as e: + logger.error('Error processing: %s', e, exc_info=True) + print(f"Error during translation API call: {str(e)}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error during translation API call: {str(e)}" + } diff --git a/chatbot/translate/ai4Bharat/transliterate.py b/chatbot/translate/ai4Bharat/transliterate.py new file mode 100644 index 0000000..1367105 --- /dev/null +++ b/chatbot/translate/ai4Bharat/transliterate.py @@ -0,0 +1,84 @@ +import os +import traceback +import requests +from chatbot.translate.ai4Bharat.base_translation import get_service_id +import logging + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") +ai4bharat_base_url = os.getenv("BHASHANI_BASE_URL") +ai4bharat_user_id = os.getenv("BHASHANI_USER_ID") +ai4bharat_authorization = os.getenv("BHASHANI_AUTHORIZATION") +logger = logging.getLogger('django') + + +def call_ai4bharat_transliterate_api(source_language, target_language, message_body, is_sentence=False): + logger.info(f"Trying to transliterate {message_body}.") + api_url = ai4bharat_base_url + service_id = None + pipeline_response = get_service_id( + task_type='transliteration', source_language=source_language, target_language=target_language + ) + if pipeline_response and pipeline_response.get('success'): + service_id = pipeline_response.get('service_id', '') + print("service_id: ", service_id) + + payload = { + "pipelineTasks": [ + { + "taskType": "transliteration", + "config": { + "language": { + "sourceLanguage": source_language, + "targetLanguage": target_language, + }, + "serviceId": service_id, + "isSentence": is_sentence, + "numSuggestions": 7 + } + } + ], + "inputData": { + "input": [ + { + "source": message_body + } + ] + } + } + + headers = { + 'accept': '*/*', + 'content-type': 'application/json', + 'Authorization': ai4bharat_authorization, + 'userID': ai4bharat_user_id, + 'ulcaApiKey': ai4bharat_api_key + } + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=10) + print("Response: ", response) + print("Res text: ", response.json()) + logger.info(f"Response from AI4Bharat Transliteration: {response}") + logger.info(f"JSON Response from AI4Bharat Transliteration: {response.json()}") + if response.status_code == 200: + transliteration_message_data = response.json() + if isinstance(transliteration_message_data, dict) and 'pipelineResponse' in transliteration_message_data: + transliteration_message = transliteration_message_data['pipelineResponse'][0].get('output', [{}])[0].get('target', '') + + print("transliteration: ", transliteration_message) + return { + 'status': 200, + 'content': transliteration_message + } + return { + 'status': 200, + 'content': message_body + } + except Exception as e: + print(f"Error during transliteration API call: {str(e)}") + logger.error(f"Error during transliteration API call: {str(e)}") + traceback.print_exc() + return { + 'status': 500, + 'content': message_body + } diff --git a/chatbot/translate/base/speech_to_text.py b/chatbot/translate/base/speech_to_text.py new file mode 100644 index 0000000..cef2f88 --- /dev/null +++ b/chatbot/translate/base/speech_to_text.py @@ -0,0 +1,65 @@ +import io +import traceback +import wave +from pydub import AudioSegment +import logging + +logger = logging.getLogger('django') + + +def is_silent_chunk(audio_bytes: bytes, format="wav", silence_thresh_dbfs=-40): + try: + audio = AudioSegment.from_file(io.BytesIO(audio_bytes), format=format) + return audio.dBFS < silence_thresh_dbfs + except Exception: + traceback.print_exc() + return False + +def split_audio(audio_bytes, chunk_duration=10): + """ + Splits audio into strictly 50-second chunks and skips silent ones. + """ + with wave.open(io.BytesIO(audio_bytes), "rb") as wf: + frame_rate = wf.getframerate() + num_channels = wf.getnchannels() + samp_width = wf.getsampwidth() + total_frames = wf.getnframes() + chunk_frames = chunk_duration * frame_rate # Frames per 50s chunk + + chunks = [] + i = 0 + chunk_number = 0 + + while i < total_frames: + remaining_frames = total_frames - i + chunk_size = min(chunk_frames, remaining_frames) + + wf.setpos(i) + chunk_data = wf.readframes(chunk_size) + + output = io.BytesIO() + with wave.open(output, "wb") as chunk_wf: + chunk_wf.setnchannels(num_channels) + chunk_wf.setsampwidth(samp_width) + chunk_wf.setframerate(frame_rate) + chunk_wf.writeframes(chunk_data) + + chunk_audio_bytes = output.getvalue() + if is_silent_chunk(chunk_audio_bytes): + logger.info(f"Skipping silent chunk {chunk_number}") + else: + chunk_seconds = chunk_size / frame_rate + chunk_kb = len(chunk_audio_bytes) / 1024 + logger.info("Chunk %s: %.2f sec, %.2f KB", chunk_number, chunk_seconds, chunk_kb) + + chunks.append((chunk_number, chunk_audio_bytes)) + + # chunk_seconds = chunk_size / frame_rate + # chunk_kb = len(output.getvalue()) / 1024 + # logger.info("Chunk %s: %.2f sec, %.2f KB", chunk_number, chunk_seconds, chunk_kb) + + # chunks.append((chunk_number, output.getvalue())) + i += chunk_size + chunk_number += 1 + + return chunks diff --git a/chatbot/translate/custom/custom_llm.py b/chatbot/translate/custom/custom_llm.py new file mode 100644 index 0000000..05104a6 --- /dev/null +++ b/chatbot/translate/custom/custom_llm.py @@ -0,0 +1,100 @@ +import json +from chatbot.llm_models.llm_script import handle_openai_model, handle_bedrock_model +from chatbot.models import LLMProvider +import json_repair +import logging + + +logger = logging.getLogger('django') + +def handle_custom_translation(company_bot, message_body, source_language, target_language): + + try: + if company_bot and company_bot.provider == LLMProvider.OPENAI: + messages = [ + { + 'role': 'user', + 'content': message_body + } + ] + + context = company_bot.context or "" + context += f"\nSource language: {source_language}\nTarget language: {target_language}" + context += f"\n{company_bot.end_context}" if company_bot.end_context else "" + system_prompt = [{"role": "system", "content": context}] + + tools = None + tool_choice = None + try: + tool_context = json_repair.repair_json(company_bot.tool_context, return_objects=True) + if tool_context: + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using bot tool_context") + except Exception as e: + logger.error(f"Failed to parse bot tool_context: {e}") + + logger.info("-----------------OPENAI OBJECTIVES---------------------------------", ) + logger.info(f"openai system_prompt: {system_prompt}") + logger.info(f"openai messages: {messages}") + response = handle_openai_model( + messages=messages, system_prompt=system_prompt, max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice=tool_choice, tools=tools, stream=False, is_json_response=True + ) + elif company_bot and company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + messages = [{ + 'role': 'user', + 'content': [{'text': message_body}] + }] + + context = company_bot.context or "" + context += f"\nSource language: {source_language}\nTarget language: {target_language}" + system_prompt = [ + { + 'text': context + } + ] + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + else: + raise ValueError("Unsupported LLM provider") + + parsed_output = parse_llm_response(response) + + return { + "status": 200, + "content": parsed_output + } + + except Exception as e: + import traceback + traceback.print_exc() + return { + "status": 500, + "content": message_body + } + +def parse_llm_response(response): + print("llm response:", response) + + if isinstance(response, str): + response = json.loads(response) + + if not isinstance(response, dict): + raise ValueError("INVALID_LLM_RESPONSE") + + output = response.get("output") + + if not isinstance(output, str): + raise ValueError("INVALID_OUTPUT_FORMAT") + + return output diff --git a/chatbot/translate/google/google_glossary.py b/chatbot/translate/google/google_glossary.py new file mode 100644 index 0000000..a91d60e --- /dev/null +++ b/chatbot/translate/google/google_glossary.py @@ -0,0 +1,154 @@ +""" +Google Cloud Translation glossary helpers (CSV → GCS → create glossary). + +Used from Django admin after Voice rows are saved. Runtime translation reads +glossary_id + location from Voice.other_params via google_translate.py (unchanged). +""" +from __future__ import annotations + +import csv +import io +import logging +import os +from typing import Any, List, Optional, Tuple + +from django.conf import settings +from google.api_core.exceptions import NotFound +from google.cloud import storage, translate +from google.oauth2 import service_account + +from chatbot.models.company_models import Voice +from chatbot.translate.google import google_translate + +logger = logging.getLogger("django") + + +def normalize_glossary_entries(raw: Any) -> List[Tuple[str, str]]: + """Canonical (source, target) pairs for change detection and CSV build.""" + if not raw or not isinstance(raw, list): + return [] + pairs: List[Tuple[str, str]] = [] + for row in raw: + if isinstance(row, (list, tuple)) and len(row) >= 2: + src, tgt = str(row[0]).strip(), str(row[1]).strip() + elif isinstance(row, dict): + src = str(row.get("source", "")).strip() + tgt = str(row.get("target", "")).strip() + else: + continue + if not src and not tgt: + continue + pairs.append((src, tgt)) + return sorted(pairs) + + +def build_csv(entries: List[Tuple[str, str]]) -> str: + """Unidirectional glossary CSV (no header): one source,target per row.""" + buf = io.StringIO() + writer = csv.writer(buf) + for src, tgt in entries: + writer.writerow([src, tgt]) + return buf.getvalue() + + +def _glossary_bucket_name() -> Optional[str]: + return os.environ.get("GLOSSARY_GCS_BUCKET") or os.environ.get("GCS_BUCKET_NAME") + + +def upload_csv(project_id: str, bucket_name: str, object_name: str, csv_text: str) -> str: + credentials = service_account.Credentials.from_service_account_file(settings.SECRETS_JSON_PATH) + client = storage.Client(project=project_id, credentials=credentials) + bucket = client.bucket(bucket_name) + blob = bucket.blob(object_name) + blob.upload_from_string(csv_text, content_type="text/csv") + return f"gs://{bucket_name}/{object_name}" + + +def delete_glossary_if_exists(client: translate.TranslationServiceClient, glossary_resource_name: str) -> None: + try: + operation = client.delete_glossary(name=glossary_resource_name) + operation.result(timeout=180) + logger.info("Deleted existing glossary: %s", glossary_resource_name) + except NotFound: + logger.debug("No existing glossary to delete: %s", glossary_resource_name) + except Exception: + logger.error( + "Failed while deleting glossary: %s", + glossary_resource_name, + exc_info=True + ) + + +def create_glossary( + client: translate.TranslationServiceClient, + parent: str, + glossary_id: str, + source_language_code: str, + target_language_code: str, + gcs_uri: str, +) -> translate.Glossary: + glossary_name = f"{parent}/glossaries/{glossary_id}" + glossary = translate.Glossary( + name=glossary_name, + language_pair=translate.Glossary.LanguageCodePair( + source_language_code=source_language_code, + target_language_code=target_language_code, + ), + input_config=translate.GlossaryInputConfig( + gcs_source=translate.GcsSource(input_uri=gcs_uri) + ), + ) + operation = client.create_glossary(parent=parent, glossary=glossary) + result = operation.result(timeout=180) + logger.info("Glossary created: %s entry_count=%s", result.name, result.entry_count) + return result + + +def sync_glossary_for_voice(voice: Voice) -> None: + """ + Build CSV from other_params['glossary_entries'], upload to GCS, recreate glossary in GCP, + then persist glossary_id and location via queryset update (avoids Voice.save() side effects). + """ + params = dict(voice.other_params or {}) + entries = normalize_glossary_entries(params.get("glossary_entries")) + if not entries: + return + + project_id = (getattr(settings, "SECRETS", None) or {}).get("project_id") + if not project_id: + raise ValueError("project_id missing from settings.SECRETS; cannot sync glossary") + + bucket_name = _glossary_bucket_name() + if not bucket_name: + raise ValueError( + "GCS bucket not configured: set GLOSSARY_GCS_BUCKET or GCS_BUCKET_NAME in the environment" + ) + + source_lang = (params.get("glossary_source_language_code") or "en").strip() + target_lang = (params.get("glossary_target_language_code") or "te").strip() + location = (params.get("location") or "us-central1").strip() + + glossary_id = (params.get("glossary_id") or "").strip() + if not glossary_id: + glossary_id = f"glossary-voice-{voice.id}-{source_lang}-{target_lang}".lower().replace("_", "-") + + csv_text = build_csv(entries) + object_name = f"glossaries/voices/{voice.id}/{glossary_id}.csv" + gcs_uri = upload_csv(project_id, bucket_name, object_name, csv_text) + + client = google_translate._get_client() + parent = f"projects/{project_id}/locations/{location}" + glossary_resource_name = f"{parent}/glossaries/{glossary_id}" + delete_glossary_if_exists(client, glossary_resource_name) + create_glossary(client, parent, glossary_id, source_lang, target_lang, gcs_uri) + + params["glossary_id"] = glossary_id + params["location"] = location + Voice.objects.filter(pk=voice.pk).update(other_params=params) + voice.other_params = params + + logger.info( + "Google glossary synced successfully for Voice id=%s glossary_id=%s", + voice.pk, + glossary_id + ) \ No newline at end of file diff --git a/chatbot/translate/google/google_stt.py b/chatbot/translate/google/google_stt.py new file mode 100644 index 0000000..180b361 --- /dev/null +++ b/chatbot/translate/google/google_stt.py @@ -0,0 +1,141 @@ +import traceback +from typing import List +from google.cloud.speech_v2 import SpeechClient +from google.cloud.speech_v2.types import cloud_speech +from google.api_core.client_options import ClientOptions +import base64 +import concurrent.futures +import logging + +from chatbot.translate.base.speech_to_text import is_silent_chunk, split_audio + +logger = logging.getLogger('django') + + +def transcribe_chunk(client, project_id, location, config, chunk_number, chunk): + """Transcribes a single chunk of audio.""" + request = cloud_speech.RecognizeRequest( + recognizer=f"projects/{project_id}/locations/{location}/recognizers/_", + config=config, + content=chunk, + ) + try: + response = client.recognize(request=request) + transcript = "" + if response and not isinstance(response, str): + print("response: ", response) + for res_result in response.results: + print("res_result: ", res_result) + if res_result and res_result.alternatives: + transcript += res_result.alternatives[0].transcript + " " + return (chunk_number, transcript.strip()) + except Exception as e: + logger.error( + 'Error during API request for chunk %s : %s | location=%s recognizer=%s', + chunk_number, + e, + location, + request.recognizer, + exc_info=True, + ) + traceback.print_exc() + return (chunk_number, "") + + +def transcribe_multiple_languages_v2( + project_id: str, + language_codes: List[str], + audio_file: str, + voice_provider: any +) -> dict: + + try: + other_params = voice_provider.other_params or {} + location = other_params.get("location", "global") + + client_options = None + + if location != "global": + client_options = ClientOptions( + api_endpoint=f"{location}-speech.googleapis.com" + ) + + client = SpeechClient(client_options=client_options) + + config_kwargs = { + "auto_decoding_config": cloud_speech.AutoDetectDecodingConfig(), + "language_codes": language_codes, + "model": other_params.get("model", "latest_long"), + } + + # -------- FEATURES -------- + features_kwargs = {} + + feature_params = [ + "enable_automatic_punctuation", + "enable_spoken_punctuation", + "enable_spoken_emojis", + "enable_word_time_offsets", + "profanity_filter", + "max_alternatives" + ] + + for param in feature_params: + if param in other_params: + features_kwargs[param] = other_params[param] + + if features_kwargs: + config_kwargs["features"] = cloud_speech.RecognitionFeatures(**features_kwargs) + + # -------- BOOST WORDS -------- + if other_params.get("boost_words"): + + phrases = [] + + for item in other_params["boost_words"]: + if isinstance(item, dict): + word = item.get("word") + else: + word = item + + if word: + phrases.append({"value": word}) + + config_kwargs["adaptation"] = { + "phrase_sets": [ + { + "inline_phrase_set": { + "phrases": phrases + } + } + ] + } + + config = cloud_speech.RecognitionConfig(**config_kwargs) + + audio_bytes = base64.b64decode(audio_file) + duration = int(other_params.get('chunk_duration', 10)) + chunks = split_audio(audio_bytes, chunk_duration=duration) + + with concurrent.futures.ThreadPoolExecutor() as executor: + future_to_chunk = { + executor.submit( + transcribe_chunk, client, project_id, location, config, chunk_number, chunk + ): chunk_number + for chunk_number, chunk in chunks + } + + results = [] + for future in concurrent.futures.as_completed(future_to_chunk): + chunk_number, transcript = future.result() + results.append((chunk_number, transcript)) + + results.sort() # Ensure correct order + full_transcript = " ".join(transcript for _, transcript in results) + + return {'status': 200, 'content': full_transcript} + + except Exception as e: + logger.error('Error processing file: %s', e, exc_info=True) + traceback.print_exc() + return {'status': 500, 'content': f"Error processing file: {e}"} diff --git a/chatbot/translate/google/google_stt_v1.py b/chatbot/translate/google/google_stt_v1.py new file mode 100644 index 0000000..b0e8a87 --- /dev/null +++ b/chatbot/translate/google/google_stt_v1.py @@ -0,0 +1,65 @@ +import traceback +from typing import List +from google.cloud import speech +from google.cloud.speech_v1 import types +import base64 + + +def transcribe_multiple_languages_v1( + language_codes: List[str], + audio_file: str, +) -> dict: + client = speech.SpeechClient() + + try: + + audio_bytes = base64.b64decode(audio_file) + + audio = speech.RecognitionAudio(content=audio_bytes) + config = types.RecognitionConfig( + encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16, + # sample_rate_hertz=16000, + language_code=language_codes[0], + alternative_language_codes=language_codes[1:] if len(language_codes) > 1 else [], + model="default", + ) + + try: + operation = client.long_running_recognize(config=config, audio=audio) + response = operation.result(timeout=600) + if not response or not response.results: + print("No response or results received.") + return {'status': 500, 'content': "No transcription results."} + + except Exception as e: + print(f"Error during API request: {e}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error during API request: {e}" + } + if not response or isinstance(response, str): + print("response: ", response) + transcripts = [] + for res_result in response.results: + if not res_result.alternatives: + continue + transcript = res_result.alternatives[0].transcript + transcripts.append(transcript) + print(f"Transcript: {transcript}") + + print("transcripts: ", transcripts) + full_transcript = " ".join(transcripts) + print("full_transcript: ", full_transcript) + return { + 'status': 200, + 'content': full_transcript + } + + except Exception as e: + print(f"Error processing file: {e}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error processing file: {e}" + } diff --git a/chatbot/translate/google/google_translate.py b/chatbot/translate/google/google_translate.py new file mode 100644 index 0000000..fe6cc50 --- /dev/null +++ b/chatbot/translate/google/google_translate.py @@ -0,0 +1,72 @@ +import traceback +import threading +from google.cloud import translate +import logging +from google.oauth2 import service_account +from django.conf import settings + +logger = logging.getLogger('django') + +_client_lock = threading.Lock() +_translation_client = None + + +def _get_client(): + global _translation_client + if _translation_client is None: + with _client_lock: + if _translation_client is None: + credentials = service_account.Credentials.from_service_account_file(settings.SECRETS_JSON_PATH) + _translation_client = translate.TranslationServiceClient(credentials=credentials) + return _translation_client + + +def translate_text( + text: str, + project_id: str, + source_language_code: str, + target_language_code: str, + voice_provider: any, +): + """Translating Text.""" + try: + + other_params = voice_provider.other_params if voice_provider.other_params else {} + + client = _get_client() + + location = other_params.get("location", "global") + parent = f"projects/{project_id}/locations/{location}" + glossary_id = other_params.get("glossary_id") + + request = { + "parent": parent, + "contents": [text], + "mime_type": "text/plain", + "source_language_code": source_language_code, + "target_language_code": target_language_code, + } + + if glossary_id: + glossary = client.glossary_path(project_id, location, glossary_id) + glossary_config = translate.TranslateTextGlossaryConfig(glossary=glossary) + request["glossary_config"] = glossary_config + + response = client.translate_text(request=request) + logger.info(f"Response from Google Text Translate: {response}") + translations = response.glossary_translations if glossary_id else response.translations + + for translation in translations: + return { + 'status': 200, + 'content': translation.translated_text + } + + except Exception as e: + logger.error(f'Error processing Google Text Translate:{e}') + print(f"Error during translation API call: {str(e)}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error during translation API call: {str(e)}" + } diff --git a/chatbot/translate/google/google_tts.py b/chatbot/translate/google/google_tts.py new file mode 100644 index 0000000..e1530f4 --- /dev/null +++ b/chatbot/translate/google/google_tts.py @@ -0,0 +1,51 @@ +"""Synthesizes speech from the input string of text or ssml. +Make sure to be working in a virtual environment. + +Note: ssml must be well-formed according to: + https://www.w3.org/TR/speech-synthesis/ +""" +import base64 +import traceback +from google.cloud import texttospeech + +from chatbot.models import GenderChoices + + +def google_text_to_speech(message, language_code, voice_provider): + try: + client = texttospeech.TextToSpeechClient() + synthesis_input = texttospeech.SynthesisInput(text=message) + gender_mapping = { + GenderChoices.MALE: texttospeech.SsmlVoiceGender.MALE, + GenderChoices.FEMALE: texttospeech.SsmlVoiceGender.FEMALE + } + gender_value = voice_provider.gender if voice_provider else None + ssml_gender = gender_mapping.get(gender_value, texttospeech.SsmlVoiceGender.NEUTRAL) + + voice = texttospeech.VoiceSelectionParams( + language_code=language_code, ssml_gender=ssml_gender, name=voice_provider.name + ) + + audio_config = texttospeech.AudioConfig( + audio_encoding=texttospeech.AudioEncoding.MP3, + speaking_rate=voice_provider.voice_speed + ) + + response = client.synthesize_speech( + input=synthesis_input, voice=voice, audio_config=audio_config + ) + audio_content = response.audio_content + audio_base64 = base64.b64encode(audio_content).decode("utf-8") + + return { + 'status': 200, + 'content': audio_base64 + } + + except Exception as e: + print(f"Error while processing: {e}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error while processing: {e}" + } diff --git a/chatbot/translate/openai/openai_stt.py b/chatbot/translate/openai/openai_stt.py new file mode 100644 index 0000000..18270fc --- /dev/null +++ b/chatbot/translate/openai/openai_stt.py @@ -0,0 +1,83 @@ +import base64 +import io +import os +from openai import OpenAI +import logging +from chatbot.translate.base.speech_to_text import split_audio + +logger = logging.getLogger('django') + + +def transcribe_audio( + base64_audio: str, + audio_format: str, + source_language: str, + voice_provider: any +) -> dict: + + try: + + client_api_key = os.getenv("OPENAI_API_KEY") + print("client_api_key: ", client_api_key) + client = OpenAI(api_key=client_api_key) + + other_params = voice_provider.other_params or {} + + model = other_params.get("model", "whisper-1") + response_format = other_params.get("response_format", "text") + temperature = other_params.get("temperature", 0) + chunk_duration = int(other_params.get("chunk_duration", 300)) + dictionary = other_params.get("dictionary", []) + + dictionary_prompt = None + if dictionary and len(dictionary)> 0: + dictionary_prompt = "Vocabulary: " + ", ".join(dictionary) + + audio_bytes = base64.b64decode(base64_audio) + print("Audio size:", len(audio_bytes)) + # -------- CHUNK AUDIO -------- + chunks = split_audio(audio_bytes, chunk_duration=chunk_duration) + print("Number of chunks:", len(chunks)) + transcripts = [] + + for chunk_number, chunk in chunks: + print("Sending chunk:", chunk_number, "size:", len(chunk)) + + audio_file = io.BytesIO(chunk) + audio_file.name = f"audio.{audio_format}" + + params = { + "model": model, + "file": audio_file, + "response_format": response_format, + "temperature": temperature, + } + + if source_language: + params["language"] = source_language + + if dictionary_prompt: + params["prompt"] = dictionary_prompt + + transcription = client.audio.transcriptions.create(**params) + print("transcription: ", transcription) + + if isinstance(transcription, str): + transcripts.append(transcription) + else: + transcripts.append(str(transcription)) + + full_transcript = " ".join(transcripts) + + return { + "status": 200, + "content": full_transcript + } + + except Exception as e: + logger.error("Error processing file: %s", e, exc_info=True) + + return { + "status": 500, + "content": f"Error during API request: {e}" + } diff --git a/chatbot/translate/sarvam/sarvam.py b/chatbot/translate/sarvam/sarvam.py new file mode 100644 index 0000000..c0450de --- /dev/null +++ b/chatbot/translate/sarvam/sarvam.py @@ -0,0 +1,172 @@ +import os +import re +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from sarvamai import SarvamAI + + +logger = logging.getLogger("django") + + +class SarvamLanguageService: + def __init__(self, api_key=None, max_workers=5): + self.api_key = api_key or os.getenv("SARVAM_API_KEY") + self.client = SarvamAI(api_subscription_key=self.api_key) + self.max_workers = max_workers + + @staticmethod + def split_text_into_chunks(text, max_chars=990): + """ + Splits text into chunks under max_chars. + Tries to split on sentence boundaries (., ?, !). + Falls back to word-safe chunks if punctuation is missing. + """ + chunks = [] + sentence_end_pattern = re.compile(r'(?<=[.!?])\s+') + sentences = sentence_end_pattern.split(text) + + current_chunk = "" + for sentence in sentences: + if not sentence.strip(): + continue + + if len(current_chunk) + len(sentence) + 1 <= max_chars: + current_chunk += (" " if current_chunk else "") + sentence + else: + if current_chunk: + chunks.append(current_chunk.strip()) + if len(sentence) <= max_chars: + current_chunk = sentence + else: + # Fallback to word-safe chunking if sentence too long + words = sentence.split() + word_chunk = "" + for word in words: + if len(word_chunk) + len(word) + 1 <= max_chars: + word_chunk += (" " if word_chunk else "") + word + else: + chunks.append(word_chunk.strip()) + word_chunk = word + if word_chunk: + current_chunk = word_chunk + else: + current_chunk = "" + if current_chunk: + chunks.append(current_chunk.strip()) + + logger.info(f"[Chunking] Total Chunks Created: {len(chunks)}") + return chunks + + def _process_in_parallel(self, chunks, worker_func): + results = [None] * len(chunks) + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures = { + executor.submit(worker_func, chunks[i]): i + for i in range(len(chunks)) + } + + for future in as_completed(futures): + index = futures[future] + results[index] = future.result() + + return " ".join(results) + + def _execute_text_task( + self, method_name, response_attr, chunks, base_kwargs_builder, extra_kwargs=None, + ): + try: + extra_kwargs = extra_kwargs or {} + + def worker(chunk): + try: + base_kwargs = base_kwargs_builder(chunk) + + def normalize_value(v): + if isinstance(v, str) and v.lower() in ("true", "false"): + return v.lower() == "true" + return v + + kwargs = { + **base_kwargs, + **{ + k: normalize_value(v) + for k, v in extra_kwargs.items() + if v is not None + }, + } + + method = getattr(self.client.text, method_name) + response = method(**kwargs) + logger.info(f"Response {response}") + + return getattr(response, response_attr, chunk) + + except Exception: + logger.error(f"{method_name} error") + return chunk + + return self._process_in_parallel(chunks, worker) + + except Exception: + logger.error(f"{method_name} failed") + raise + + def transliterate( + self, input_text, source_lang, target_lang, max_chars=990, voice_provider=None, + ): + chunks = self.split_text_into_chunks(input_text, max_chars) + + other = getattr(voice_provider, "other_params", {}) or {} + + def base_kwargs_builder(chunk): + return { + "input": chunk, + "source_language_code": source_lang, + "target_language_code": target_lang, + } + + return { + "status": 200, + "content": self._execute_text_task( + method_name="transliterate", + response_attr="transliterated_text", + chunks=chunks, + base_kwargs_builder=base_kwargs_builder, + extra_kwargs={ + "numerals_format": other.get("numerals_format"), + "spoken_form": other.get("spoken_form"), + "spoken_form_numerals_language": other.get("spoken_form_numerals_language"), + }, + ), + } + + def translate(self, input_text, source_lang, target_lang, max_chars=990, voice_provider=None): + chunks = self.split_text_into_chunks(input_text, max_chars) + + other = getattr(voice_provider, "other_params", {}) or {} + gender = getattr(voice_provider, "gender", None) + + def base_kwargs_builder(chunk): + return { + "input": chunk, + "source_language_code": source_lang, + "target_language_code": target_lang, + "speaker_gender": gender, + } + + return { + "status": 200, + "content": self._execute_text_task( + method_name="translate", + response_attr="translated_text", + chunks=chunks, + base_kwargs_builder=base_kwargs_builder, + extra_kwargs={ + "model": other.get("model"), + "mode": other.get("mode"), + "output_script": other.get("output_script"), + "numerals_format": other.get("numerals_format"), + }, + ), + } diff --git a/chatbot/translate/sarvam/speech_to_text.py b/chatbot/translate/sarvam/speech_to_text.py new file mode 100644 index 0000000..b6f3082 --- /dev/null +++ b/chatbot/translate/sarvam/speech_to_text.py @@ -0,0 +1,92 @@ +import base64 +import os +import tempfile +import traceback +import concurrent.futures +from chatbot.translate.google.google_stt import split_audio +from sarvamai import SarvamAI +import logging + + +sarvam_api_key = os.getenv("SARVAM_API_KEY") +logger = logging.getLogger('django') + + +def transcribe_single_chunk( + client, chunk_number, chunk, audio_format, source_language, model, mode +): + + try: + with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=True) as tmp_file: + tmp_file.write(chunk) + tmp_file.flush() + + with open(tmp_file.name, "rb") as f: + params = { + "file": f, + "model": model, + "language_code": source_language, + } + + if mode: + params["mode"] = mode + + response = client.speech_to_text.transcribe(**params) + print("response: ", response) + if hasattr(response, "transcript"): + return (chunk_number, response.transcript) + + return (chunk_number, "") + + except Exception: + traceback.print_exc() + return (chunk_number, "") + + +def transcribe_sarvam_multiple_chunks( + voice_provider, + base64_audio_file, + source_language, + audio_format="wav", +): + + try: + + other_params = voice_provider.other_params or {} + + duration = int(other_params.get("chunk_duration", 10)) + model = other_params.get("model", "saaras:v3") + mode = other_params.get("mode", "transcribe") + + audio_bytes = base64.b64decode(base64_audio_file) + + chunks = split_audio(audio_bytes, chunk_duration=duration) + client = SarvamAI(api_subscription_key=sarvam_api_key) + + with concurrent.futures.ThreadPoolExecutor() as executor: + + futures = [ + executor.submit( + transcribe_single_chunk, client, chunk_number, chunk, audio_format, + source_language, model, mode + ) + for chunk_number, chunk in chunks + ] + + transcripts = [ + future.result() + for future in concurrent.futures.as_completed(futures) + ] + + transcripts.sort() + + transcript = " ".join(content for _, content in transcripts) + + return {"status": 200, "content": transcript} + + except Exception as e: + logger.error("Error processing: %s", e, exc_info=True) + traceback.print_exc() + + return {"status": 500, "content": str(e)} + diff --git a/chatbot/translate/sarvam/text_to_speech.py b/chatbot/translate/sarvam/text_to_speech.py new file mode 100644 index 0000000..82d65e7 --- /dev/null +++ b/chatbot/translate/sarvam/text_to_speech.py @@ -0,0 +1,73 @@ +import os +import traceback +import requests +import re + +from chatbot.models import LanguageMapping + + +def sarvam_text_to_speech(message, source_language, voice_provider): + try: + api_key = os.getenv("SARVAM_API_KEY") + if not api_key: + return { + 'status': 500, + 'content': 'SARVAM_API_KEY is not configured' + } + + other = voice_provider.other_params if voice_provider and voice_provider.other_params else {} + + requested_speaker = other.get("speaker") + + # Guard against values like od-IN / en-IN mistakenly saved in name or speaker. + if not requested_speaker and voice_provider and voice_provider.name: + name_value = voice_provider.name.strip().lower() + if not re.fullmatch(r"[a-z]{2,3}-[a-z]{2}", name_value): + requested_speaker = name_value + + payload = { + "text": message, + "target_language_code": LanguageMapping.get_sarvam_language(source_language), + "model": other.get("model", "bulbul:v3"), + "speaker": requested_speaker or "shubh", + "speech_sample_rate": other.get("speech_sample_rate", 24000), + "output_audio_codec": other.get("output_audio_codec", "wav"), + "pace": other.get("pace", 1.0), + "temperature": other.get("temperature", 0.6), + } + + response = requests.post( + "https://api.sarvam.ai/text-to-speech", + headers={ + "api-subscription-key": api_key, + "Content-Type": "application/json", + }, + json=payload, + timeout=20, + ) + + if response.status_code != 200: + return { + 'status': response.status_code, + 'content': response.text + } + + body = response.json() + audios = body.get("audios", []) + if not audios: + return { + 'status': 500, + 'content': 'No audio returned from Sarvam TTS' + } + + return { + 'status': 200, + 'content': audios[0] + } + + except Exception as e: + traceback.print_exc() + return { + 'status': 500, + 'content': str(e) + } diff --git a/chatbot/translate/sarvam/translate.py b/chatbot/translate/sarvam/translate.py new file mode 100644 index 0000000..f6fc83d --- /dev/null +++ b/chatbot/translate/sarvam/translate.py @@ -0,0 +1,139 @@ +import os +import re +import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed +from sarvamai import SarvamAI +import logging + + +logger = logging.getLogger('django') +sarvam_api_key = os.getenv("SARVAM_API_KEY") + + +def split_text_into_chunks_safely(text, max_chars=990): + """ + Splits text into chunks under max_chars. + Tries to split on sentence boundaries (., ?, !). + Falls back to word-safe chunks if punctuation is missing. + """ + chunks = [] + sentence_end_pattern = re.compile(r'(?<=[.!?])\s+') + sentences = sentence_end_pattern.split(text) + + current_chunk = "" + for sentence in sentences: + if not sentence.strip(): + continue + + if len(current_chunk) + len(sentence) + 1 <= max_chars: + current_chunk += (" " if current_chunk else "") + sentence + else: + if current_chunk: + chunks.append(current_chunk.strip()) + if len(sentence) <= max_chars: + current_chunk = sentence + else: + # Fallback to word-safe chunking if sentence too long + words = sentence.split() + word_chunk = "" + for word in words: + if len(word_chunk) + len(word) + 1 <= max_chars: + word_chunk += (" " if word_chunk else "") + word + else: + chunks.append(word_chunk.strip()) + word_chunk = word + if word_chunk: + current_chunk = word_chunk + else: + current_chunk = "" + if current_chunk: + chunks.append(current_chunk.strip()) + + print(f"[Chunking] Total Chunks Created: {len(chunks)}") + return chunks + + +def translate_chunk(client, chunk, source_lang, target_lang, gender, mode, output_script, enable_preprocessing): + """ + Worker function for translating a single chunk. + """ + try: + response = client.text.translate( + input=chunk, + source_language_code=source_lang, + target_language_code=target_lang, + speaker_gender=gender, + mode=mode, + output_script=output_script, + enable_preprocessing=enable_preprocessing, + ) + logger.info(f"Response {response}") + + translated = response.translated_text if hasattr(response, 'translated_text') else chunk + print(f"[Translate] Done. Translated length: {len(translated)}") + + return translated + except Exception as e: + logger.error('Error processing: %s', e, exc_info=True) + print(f"Error translating chunk: {chunk[:30]}... - {str(e)}") + return chunk + + +def sarvam_translate_text(voice_provider, input_text, source_lang, target_lang, gender, max_chars=990): + try: + client = SarvamAI( + api_subscription_key=sarvam_api_key + ) + mode="formal" + output_script="fully-native" + enable_preprocessing = True + + other_params = voice_provider.other_params + if other_params: + mode = other_params.get('mode') + output_script = other_params.get('output_script') + enable_preprocessing = other_params.get('enable_preprocessing') + if enable_preprocessing: + enable_preprocessing = str(other_params.get('enable_preprocessing', True)).lower() == 'true' + + chunks = split_text_into_chunks_safely(text=input_text, max_chars=max_chars) + + translated_chunks = [None] * len(chunks) + print(f"[Translate] Submitting {len(chunks)} chunks for parallel translation...") + + with ThreadPoolExecutor() as executor: + futures = { + executor.submit( + translate_chunk, + client, + chunks[i], + source_lang, + target_lang, + gender, + mode, + output_script, + enable_preprocessing + ): i for i in range(len(chunks)) + } + + for future in as_completed(futures): + index = futures[future] + translated_chunks[index] = future.result() + print(f"[Translate] Chunk {index+1}/{len(chunks)} completed.") + + final_translation = " ".join(translated_chunks) + print("[Translate] All chunks translated and reassembled.") + + return { + 'status': 200, + 'content': final_translation + } + + except Exception as e: + logger.error('Error processing: %s', e, exc_info=True) + print(f"Error during translation API call: {str(e)}") + traceback.print_exc() + return { + 'status': 500, + 'content': f"Error during translation API call: {str(e)}" + } diff --git a/chatbot/urls.py b/chatbot/urls.py new file mode 100644 index 0000000..37c9752 --- /dev/null +++ b/chatbot/urls.py @@ -0,0 +1,155 @@ +from chatbot.utils.image_converter import convert_image +from chatbot.views.Media.extract_views import BatchMediaExtractView, BatchMediaRetryExtractView +from chatbot.views.Media.media_tracking_views import MediaViewTrackAPIView, MediaDownloadTrackAPIView, \ + SolutionDownloadTrackView +from chatbot.views.Media.save_views import BatchMediaSaveView, BatchMediaRetrySaveView +from chatbot.views.Media.status_views import BatchMediaTaskStatusView, VectorDBTaskStatusView +from chatbot.views.Media.upload_views import BatchMediaUploadView + +from chatbot.views.Media.document_upload_view import DocumentUploadView +from chatbot.views.admin.generic_upload_views import GenericBatchUploadView, GenericBatchTemplateView, \ + GenericBatchImportView +from chatbot.views.aws_views import get_presigned_url +from chatbot.views.gotenberg_view import generate_pdf_view, generate_pdf_view_v2 +from chatbot.views.kafka_views import sync_user_project_view +from chatbot.views.location_views import get_location_view, get_ip_location_view +from chatbot.views.Media.media_views import MediaSearchView +from chatbot.views.Media.media_api_views import FetchThemeView, MediaSearchV2View +from chatbot.views.profile_views import create_profile_views +from django.urls import path, include +from chatbot.views import api_views +from chatbot.views.bhashini_views import text_speech_view, speech_text, text_translation_view, text_transliterate_view +from chatbot.views.chat_view import save_chats_view, create_chatsession, save_ptm_chats +from chatbot.views.drf_views import CompanyChatListCreateView, CompanyChatRetrieveUpdateDestroyView, \ + CompanyBotListCreateView, CompanyBotRetrieveUpdateDestroyView, ProfileListCreateView, \ + ProfileRetrieveUpdateDestroyView, ChatSessionListCreateView, ChatSessionRetrieveUpdateDestroyView, \ + ChatSessionRetrieveUpdateDestroyViewSession, BotVernacularListCreateView, BotVernacularRetrieveUpdateDestroyView, \ + FlowImageConfigView, FlowLanguagesView, FlowConnectionInfoView +from chatbot.views.mitra_views import \ + create_project_view +from chatbot.views.recommendation import generate_recommendation +from chatbot.views.story_views import end_story, end_story_v2, StoryListCreateView, StoryBySessionView, \ + StoryRetrieveUpdateDestroyView, StoryMediaListCreateView, StoryMediaRetrieveUpdateDestroyView +from rest_framework.routers import DefaultRouter +from chatbot.views.Media.media_api_views import MediaViewSet +from chatbot.views.Media import google_drive_integration + +app_name = "chatbot" + +router = DefaultRouter() +router.register(r'media', MediaViewSet, basename='media') + + +urlpatterns = [ + path('api/profile/', api_views.post_profile), + path('api/user_profile/', ProfileListCreateView.as_view(), name='profile-list-create'), + + path('api/generate-session/', api_views.generate_session_id, name='generate_session_id'), + path('api/login/', api_views.login, name='login'), + path('api/logout/', api_views.logout, name='logout'), + + path('api/end-story/', end_story, name='end-story'), + path('api/end-story/v2/', end_story_v2, name='end-story-v2'), + + path('api/text_to_speech/', text_speech_view, name='text_speech_view'), + path('api/asr/', speech_text, name='speech_text'), + path('api/text_translate/', text_translation_view, name='text_translation_view'), + path('api/text_transliterate/', text_transliterate_view, name='text_transliterate_view'), + + path('api/companychat/', CompanyChatListCreateView.as_view(), name='companychat-list-create'), + path('api/companychat//', CompanyChatRetrieveUpdateDestroyView.as_view(), + name='companychat-retrieve-update-destroy'), + + path('api/companybot/', CompanyBotListCreateView.as_view(), name='companybot-list-create'), + path('api/companybot//', CompanyBotRetrieveUpdateDestroyView.as_view(), + name='companybot-retrieve-update-destroy'), + + path('api/bot_vernacular/', BotVernacularListCreateView.as_view(), name='bot_vernacular-list-create'), + path('api/bot_vernacular//', BotVernacularRetrieveUpdateDestroyView.as_view(), + name='bot_vernacular-retrieve-update-destroy'), + + path('api/story/', StoryListCreateView.as_view(), name='story-list-create'), + path('api/get-story/', StoryBySessionView.as_view(), name='story-by-session'), + path('api/story//', StoryRetrieveUpdateDestroyView.as_view(), + name='story-retrieve-update-destroy'), + # path('api/story-re-create/', story_recreate_view, name='story_recreate_view'), + + path('api/storymedia/', StoryMediaListCreateView.as_view(), name='story-media-list-create'), + path('api/storymedia//', StoryMediaRetrieveUpdateDestroyView.as_view(), + name='story-media-retrieve-update-destroy'), + + path('api/profileuser/', ProfileListCreateView.as_view(), name='profile-user-list-create'), + path('api/profileuser//', ProfileRetrieveUpdateDestroyView.as_view(), + name='profile-user-retrieve-update-destroy'), + + path('api/chatsession/', ChatSessionListCreateView.as_view(), name='chatsession-list-create'), + path('api/chatsession//', ChatSessionRetrieveUpdateDestroyView.as_view(), + name='chatsession-retrieve-update-destroy'), + path('api/chatsession//', ChatSessionRetrieveUpdateDestroyViewSession.as_view(), + name='chatsession-retrieve-update-destroy'), + + # Flow APIs + path('api/flow-image-config/', FlowImageConfigView.as_view(), name='flow-image-config'), + path('api/flow-languages/', FlowLanguagesView.as_view(), name='flow-languages'), + path('api/flow-connection-info/', FlowConnectionInfoView.as_view(), name='flow-connection-info'), + + path('api/save-company-chat/', save_chats_view, name="save-company-chat"), + path('api/create-chatsession/', create_chatsession, name="create-chatsession"), + path('api/create-profile/', create_profile_views, name="create-profile"), + path('api/create-project/', create_project_view, name="create-project"), + path('api/generate-pdf/', generate_pdf_view, name='generate_pdf'), + path('api/generate-pdf/v2/', generate_pdf_view_v2, name='generate_pdf_v2'), + path('api/generate-recommendation/', generate_recommendation, name='generate-recommendation'), + path('api/sync-user-project/', sync_user_project_view, name='sync-user-project'), + path('api/get-location/', get_location_view, name='get-location'), + path('api/get-ip-location/', get_ip_location_view, name='get-ip-location'), + path("api/get-presigned-url/", get_presigned_url, name='get-presigned-url'), + path("api/image-converter/", convert_image, name='image-converter'), + path('api/questions/save/', save_ptm_chats, name="save_ptm_chats"), + path("api/track-view//", MediaViewTrackAPIView.as_view(), name="media-track-view"), + path("api/track-download//", MediaDownloadTrackAPIView.as_view(), name="media-track-download"), + path("api/track-solution-download//", SolutionDownloadTrackView.as_view(), + name="project-solution-download-track"), + + path("api/search/", MediaSearchView.as_view(), name="media-search"), + path("fetch-theme", FetchThemeView.as_view(), name="fetch-theme-no-slash"), + path("fetch-theme/", FetchThemeView.as_view(), name="fetch-theme"), + + # path('admin/media/batch-upload/', BatchMediaUploadView.as_view(), name='batch_media_upload'), + path('admin/media/batch-upload/', BatchMediaUploadView.as_view(), name='chatbot_media_batch_upload'), + path('admin/media/google-drive-auth/', google_drive_integration.GoogleDriveAuthView.as_view(), name='admin_google_drive_auth'), + path('admin/media/batch-extract/', BatchMediaExtractView.as_view(), name='chatbot_media_batch_extract'), + path('admin/media/batch-save/', BatchMediaSaveView.as_view(), name='chatbot_media_batch_save'), + path('admin/media/batch-task-status/', BatchMediaTaskStatusView.as_view(), name='chatbot_media_task_status'), + + + # Retry endpoints + path('admin/media/retry-extract/', BatchMediaRetryExtractView.as_view(), name='chatbot_media_retry_extract'), + path('admin/media/retry-save/', BatchMediaRetrySaveView.as_view(), name='chatbot_media_retry_save'), + + path('admin/media/vector-db-task-status/', VectorDBTaskStatusView.as_view(), name='chatbot_media_vector_db_task_status'), + + # generic batch upload URLs + path('admin///batch-upload/', GenericBatchUploadView.as_view(), + name='generic_batch_upload'), + path('admin///batch-template/', GenericBatchTemplateView.as_view(), + name='generic_batch_template'), + path('admin///batch-import/', GenericBatchImportView.as_view(), + name='generic_batch_import'), + + + # Media API endpoints + path('api/v1/', include(router.urls)), + path('api/v1/documents', DocumentUploadView.as_view(), name='document-upload'), + path('google-drive/', google_drive_integration.GoogleDriveIntegrationView.as_view(), name='google_drive_integration'), + path('google-drive/auth/', google_drive_integration.GoogleDriveAuthView.as_view(), name='google_drive_auth'), + path('google-drive/callback/', google_drive_integration.GoogleDriveCallbackView.as_view(), name='google_drive_callback'), + path('google-drive/files/import/', google_drive_integration.GoogleDriveFileImportView.as_view(), name='google_drive_file_import'), + + # Media Search V2 - Vector Database powered search + path('api/v2/media/', MediaSearchV2View.as_view(), name='media-search-v2'), + + # AI Documents Search - Alternative endpoint for the same functionality + path('ai/documents/search', MediaSearchV2View.as_view(), name='ai-documents-search'), + +] diff --git a/chatbot/utils/S3/s3_service.py b/chatbot/utils/S3/s3_service.py new file mode 100644 index 0000000..fdf5ab5 --- /dev/null +++ b/chatbot/utils/S3/s3_service.py @@ -0,0 +1,99 @@ +import boto3 +import os +import time +import requests +from shikshalokam.models import Project + + + +def upload_file_to_s3( + *, + file_name: str, + file_content, + content_type: str, + project_id: int | None, + folder_structure: str +) -> str | None: + + try: + id_prefix = f"{project_id}/" if project_id else "" + key = f"{folder_structure}{id_prefix}{int(time.time())}-{file_name}" + + s3_client = boto3.client( + "s3", + region_name=os.getenv("AWS_REGION"), + aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), + ) + + upload_url = s3_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": os.getenv("S3_BUCKET_NAME"), + "Key": key, + "ContentType": content_type, + }, + ExpiresIn=3600, + ) + + response = requests.put( + upload_url, + data=file_content, + headers={"Content-Type": content_type}, + ) + + if response.status_code == 200: + return key + + print(f"S3 upload failed with status {response.status_code}") + return None + + except Exception as e: + print(f"S3 upload error: {str(e)}") + return None + +def upload_media( + *, + project_id: int, + media_type: str, + file_name: str, + file_content, + content_type: str, + folder_structure: str = "shikshagraha_commons/", +): + + s3_key = upload_file_to_s3( + file_name=file_name, + file_content=file_content, + content_type=content_type, + project_id=project_id, + folder_structure=folder_structure, + ) + + if not s3_key: + return None + + base = os.getenv("S3_MEDIA_URL") + media_url = f"{base}{s3_key}" + + existing_other_params = ( + Project.objects.filter(id=project_id) + .values_list("other_params", flat=True) + .first() + or {} + ) + + existing_other_params[media_type] = { + "url": media_url, + "file_name": file_name, + } + + Project.objects.filter(id=project_id).update( + other_params=existing_other_params + ) + + return { + "media_type": media_type, + "url": media_url, + "file_name": file_name, + } \ No newline at end of file diff --git a/chatbot/utils/__init__.py b/chatbot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/utils/admin_config/config.py b/chatbot/utils/admin_config/config.py new file mode 100644 index 0000000..e2873a7 --- /dev/null +++ b/chatbot/utils/admin_config/config.py @@ -0,0 +1,58 @@ + +from enum import Enum +from typing import Dict, List, Any +from chatbot.constants.post_processing_constants import PROCESSING_TYPE_CONFIG + + +# Common fields that all processing types share +COMMON_FIELDS = ['input_file', 'date_from', 'date_till'] + + +class ProcessingType(Enum): + """ + Enum for all available post-processing types. + """ + UNIQUE_CHALLENGES = 'unique_challenges' + UNIQUE_SOLUTIONS = 'unique_solutions' + + @property + def label(self) -> str: + """Human-readable label for the processing type""" + return PROCESSING_TYPE_CONFIG[self.value]['label'] + + @property + def template_name(self) -> str: + """Template file name for the processing type's form fields""" + return PROCESSING_TYPE_CONFIG[self.value]['template_name'] + + @property + def fields(self) -> List[Dict[str, Any]]: + """Configuration for form fields specific to this processing type""" + return PROCESSING_TYPE_CONFIG[self.value]['fields'] + + @property + def handler_method(self) -> str: + """Name of the method in PostProcessingView that handles this type""" + return PROCESSING_TYPE_CONFIG[self.value]['handler_method'] + + +def get_all_processing_types() -> List[Dict[str, str]]: + return [ + { + 'value': ptype.value, + 'label': ptype.label + } + for ptype in ProcessingType + ] + + +def get_processing_type_by_value(value: str) -> ProcessingType: + for ptype in ProcessingType: + if ptype.value == value: + return ptype + return None + + +def get_processing_type_config(processing_type: ProcessingType) -> Dict[str, Any]: + """Get configuration dictionary for a specific processing type""" + return PROCESSING_TYPE_CONFIG.get(processing_type.value, {}) diff --git a/chatbot/utils/admin_config/export_mixin.py b/chatbot/utils/admin_config/export_mixin.py new file mode 100644 index 0000000..7200ec2 --- /dev/null +++ b/chatbot/utils/admin_config/export_mixin.py @@ -0,0 +1,82 @@ +import tablib +from django.http import HttpResponse, HttpResponseRedirect +from django.utils.http import urlencode +from django.urls import path +from django.utils.timezone import localtime +from django.contrib import admin + + +class ExportAllFieldsMixin: + + export_filename = "export.xlsx" + + def get_urls(self): + urls = super().get_urls() + + custom_urls = [ + path( + "export_all/", + self.admin_site.admin_view(self.export_all_view), + name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_export_all", + ), + ] + + return custom_urls + urls + + def export_all_view(self, request): + + ids = request.GET.get("ids", "") + selected_ids = ids.split(",") if ids else [] + + queryset = self.model.objects.filter(id__in=selected_ids) + + dataset = tablib.Dataset() + + fields = [field.name for field in self.model._meta.fields] + + dataset.headers = fields + + for obj in queryset: + + row = [] + + for field in fields: + + value = getattr(obj, field) + + if hasattr(value, "__str__"): + value = str(value) + + # handle datetime timezone + if hasattr(value, "tzinfo") and value.tzinfo: + value = localtime(value).replace(tzinfo=None) + + row.append(value) + + dataset.append(row) + + response = HttpResponse( + dataset.export("xlsx"), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}"' + + return response + + @admin.action(description="Export selected records") + def export_selected_records(self, request, queryset): + selected = queryset.values_list("pk", flat=True) + query_string = urlencode({"ids": ",".join(map(str, selected))}) + return HttpResponseRedirect(f"{request.path}export_all/?{query_string}") + + def get_actions(self, request): + actions = super().get_actions(request) + + actions["export_selected_records"] = ( + self.__class__.export_selected_records, + "export_selected_records", + "Export selected records", + ) + + return actions diff --git a/chatbot/utils/audio_converter_utils.py b/chatbot/utils/audio_converter_utils.py new file mode 100644 index 0000000..4626fb5 --- /dev/null +++ b/chatbot/utils/audio_converter_utils.py @@ -0,0 +1,75 @@ +import os +import uuid +import base64 +import requests +import subprocess +from tempfile import NamedTemporaryFile +import logging +from django.conf import settings + +from chatbot.services.storage import StorageFactory + + +logger = logging.getLogger('django') + + +def convert_s3_audio_to_wav_base64(s3_url: str) -> str: + try: + storage_handler = StorageFactory.get_storage_handler() + + audio_bytes = None + if s3_url.startswith("https://"): + response = requests.get(s3_url) + response.raise_for_status() + audio_bytes = response.content + + else: + audio_bytes = storage_handler.get_file_from_store(object_url=s3_url) + + + # Step 2: Save original audio to temp file + input_ext = s3_url.split('.')[-1] + logger.info("input_ext: %s", input_ext) + + input_ext = 'opus' + with NamedTemporaryFile(suffix=f".{input_ext}", delete=False) as input_file: + input_file.write(audio_bytes) + input_path = input_file.name + logger.info("Saved input audio to temporary file: %s", input_path) + + # Step 3: Define output WAV file path + output_path = f"/tmp/{uuid.uuid4().hex}.wav" + logger.info("Output WAV will be saved to: %s", output_path) + + # Step 4: Convert using FFmpeg + ffmpeg_command = [ + 'ffmpeg', + '-y', + '-i', input_path, + '-ac', '1', + '-ar', '16000', + output_path + ] + subprocess.run(ffmpeg_command, check=True) + logger.info("FFmpeg conversion successful") + + + # Step 5: Read WAV bytes + with open(output_path, 'rb') as f: + wav_bytes = f.read() + logger.info("WAV file read successfully.") + + encoded_audio = base64.b64encode(wav_bytes).decode('utf-8') + logger.info("WAV file encoded to base64") + + return encoded_audio + + finally: + # Clean up temporary files + if 'input_path' in locals() and os.path.exists(input_path): + os.remove(input_path) + logger.info(f"Deleted temporary input file: %s", input_path) + + if 'output_path' in locals() and os.path.exists(output_path): + os.remove(output_path) + logger.info(f"Deleted temporary output file: %s", output_path) diff --git a/chatbot/utils/audio_provider_utils.py b/chatbot/utils/audio_provider_utils.py new file mode 100644 index 0000000..098d8a5 --- /dev/null +++ b/chatbot/utils/audio_provider_utils.py @@ -0,0 +1,172 @@ +from chatbot.models import VoiceProvider, LanguageMapping, Voice, VoiceType, CompanyBot +from chatbot.translate.ai4Bharat.speech_to_text import transcribe_ai4bharat_multiple_chunks +from chatbot.translate.ai4Bharat.text_to_speech import ai4bharat_text_speech +from chatbot.translate.ai4Bharat.text_to_text import call_ai4bharat_translation_api +from chatbot.translate.custom.custom_llm import handle_custom_translation +from chatbot.translate.google.google_stt import transcribe_multiple_languages_v2 +from chatbot.translate.google.google_stt_v1 import transcribe_multiple_languages_v1 +from chatbot.translate.google.google_translate import translate_text +from chatbot.translate.google.google_tts import google_text_to_speech +from chatbot.translate.openai.openai_stt import transcribe_audio +from chatbot.translate.sarvam.sarvam import SarvamLanguageService +from chatbot.translate.sarvam.speech_to_text import transcribe_sarvam_multiple_chunks +from chatbot.translate.sarvam.text_to_speech import sarvam_text_to_speech +from django.conf import settings +import logging + + +logger = logging.getLogger('django') + + +def get_voice_provider(company_bot, voice_type, source_language=None, target_language=None): + """Return appropriate Voice provider preferring non-English language.""" + + language = ( + target_language if target_language and target_language.lower() != "en" + else source_language if source_language and source_language.lower() != "en" + else "en" + ) + + voice = Voice.objects.filter( + company_bot=company_bot, + type=voice_type, + language=language, + ).first() + + if not voice and language != "en": + voice = Voice.objects.filter( + company_bot=company_bot, + type=voice_type, + language="en", + ).first() + + return voice + + +def text_speech_provider(company_bot, text, source_language): + voice_provider = get_voice_provider( + company_bot=company_bot, voice_type=VoiceType.TextToSpeech, source_language=source_language + ) + if not voice_provider: + return { + 'status': 500, + 'content': "No voice configuration found!" + } + + if voice_provider.provider == VoiceProvider.AI4Bharat: + response = ai4bharat_text_speech( + text=text, gender=voice_provider.gender, source_language=source_language, + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.GOOGLE: + response = google_text_to_speech( + message=text, language_code=LanguageMapping.get_mapped_language(source_language), + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.SARVAM: + response = sarvam_text_to_speech( + message=text, source_language=source_language, voice_provider=voice_provider + ) + else: + return { + 'status': 500, + 'content': "No provider found!" + } + + return response + + +def speech_text_provider(company_bot, base64, audio_format, source_language): + voice_provider = get_voice_provider( + company_bot=company_bot, voice_type=VoiceType.SpeechToText, source_language=source_language + ) + if not voice_provider: + return { + 'status': 500, + 'content': "No voice configuration found!" + } + + if voice_provider.provider == VoiceProvider.AI4Bharat: + response = transcribe_ai4bharat_multiple_chunks( + base64_audio_file=base64, source_language=source_language, audio_format=audio_format, + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.GOOGLE: + if source_language == 'en': + region = "US" + else: + region = "IN" + + secret = settings.SECRETS + response = transcribe_multiple_languages_v2( + project_id=secret.get('project_id'), audio_file=base64, + language_codes=[LanguageMapping.get_mapped_language(source_language, region)], + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.OPENAI_WHISPER: + response = transcribe_audio( + base64_audio=base64, audio_format=audio_format, source_language=source_language, + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.SARVAM: + response = transcribe_sarvam_multiple_chunks( + base64_audio_file=base64, audio_format=audio_format, + source_language=LanguageMapping.get_sarvam_language(source_language), + voice_provider=voice_provider + ) + + else: + return { + 'status': 500, + 'content': "No provider found!" + } + return response + + +def text_translate_provider(message_body, target_language, source_language, voice_provider=None, company_bot=None): + try: + if not voice_provider and company_bot: + voice_provider = get_voice_provider( + company_bot=company_bot, voice_type=VoiceType.TextToText, source_language=source_language, + target_language=target_language + ) + if voice_provider.provider == VoiceProvider.AI4Bharat: + response = call_ai4bharat_translation_api( + source_language=source_language, target_language=target_language, message_body=message_body, + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.GOOGLE: + secret = settings.SECRETS + response = translate_text( + project_id=secret.get('project_id'), text=message_body, + source_language_code=LanguageMapping.get_google_translate_language(source_language), + target_language_code=LanguageMapping.get_google_translate_language(target_language), + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.SARVAM: + service = SarvamLanguageService() + response = service.translate( + input_text=message_body, + source_lang=LanguageMapping.get_sarvam_language(source_language), + target_lang=LanguageMapping.get_sarvam_language(target_language), + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.CUSTOM_LLM: + other = getattr(voice_provider, "other_params", {}) or {} + route = other.get('route', "/transliterate_text") + company_bot = CompanyBot.objects.filter(route=route).first() + response = handle_custom_translation( + message_body=message_body, source_language=LanguageMapping.get_mapped_language(source_language), + target_language=LanguageMapping.get_mapped_language(target_language), company_bot=company_bot + ) + else: + return { + 'status': 500, + 'content': "No provider found!" + } + return response + except Exception as e: + return { + 'status': 500, + 'content': str(e) + } diff --git a/chatbot/utils/bedrock_tool_call.py b/chatbot/utils/bedrock_tool_call.py new file mode 100644 index 0000000..2a88faf --- /dev/null +++ b/chatbot/utils/bedrock_tool_call.py @@ -0,0 +1,110 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, CompanyChat, LLMProvider +from chatbot.models.company_models import CompanyStateMachine +import logging + + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + + +def get_bedrock_tool_call_response( + system_prompt, messages, company_bot, session_id, channel_name, route, profile_id +): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + chunks = [] + + response = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + tools = [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + response = handle_openai_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tools, tool_choice='auto', is_json_response=False + ) + + print("response_body bedrock: ", response) + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + print("Response: ", response) + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + if is_function_call: + print("its func call") + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=bot_question, + chunks=chunks, status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + return response + else: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + print("its not a func call") + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=response, + chunks=chunks, status=ChatStatus.IN_PROGRESS, translated_message=translated_message, + stage=state_machine.name + ) + + return response diff --git a/chatbot/utils/chat_query_handler.py b/chatbot/utils/chat_query_handler.py new file mode 100644 index 0000000..daa6aef --- /dev/null +++ b/chatbot/utils/chat_query_handler.py @@ -0,0 +1,284 @@ +import requests +import os +from typing import List, Dict, Any, Optional +from chatbot.llm_models.llm_script import handle_bedrock_model + +DATABASE_INTERFACE_BEARER_TOKEN = os.getenv('DATABASE_INTERFACE_BEARER_TOKEN') +base_url = os.getenv('VECTOR_DB_BASE_URL') + +def query_database(query_prompt: str, priority_filter: str, limit: int): + """ + Query vector database to retrieve chunk with user's input questions. + """ + url = f"{base_url}/api/documents/search" + print("URL: ", url) + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + data = { + "query": query_prompt, + "top_k": limit, + } + # if priority_filter: + # data["priority_filter"] = priority_filter + print("DATA: ", data) + response = requests.post(url, json=data, headers=headers) + if response.status_code == 200: + result = response.json() + print("response: ", result) + # process the result + return result + else: + print(f"Error: {response.status_code} : {response.content}") + + +def query_text_search(query: str, priority: str = "P1", limit: int = 10): + """ + Query vector database using text-search API endpoint. + """ + url = f"{base_url}/api/documents/text-search" + print(f"[query_text_search] URL: {url}") + + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + + payload = { + "query": query, + "priority": priority, + "limit": limit + } + + print(f"[query_text_search] Request Payload: {payload}") + + try: + response = requests.post(url, json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + result = response.json() + print(f"[query_text_search] Success: Retrieved {result.get('total_results', 0)} results") + return result + else: + error_msg = f"Error: {response.status_code}" + try: + error_detail = response.json() + error_msg += f" - {error_detail}" + except: + error_msg += f" - {response.text}" + + print(f"[query_text_search] {error_msg}") + return { + "error": True, + "status_code": response.status_code, + "message": error_msg, + "query": query, + "total_results": 0, + "results": [] + } + + except requests.exceptions.Timeout: + error_msg = "Request timeout - Vector database took too long to respond" + print(f"[query_text_search] {error_msg}") + return { + "error": True, + "status_code": 504, + "message": error_msg, + "query": query, + "total_results": 0, + "results": [] + } + + except requests.exceptions.ConnectionError: + error_msg = "Connection error - Unable to reach vector database" + print(f"[query_text_search] {error_msg}") + return { + "error": True, + "status_code": 503, + "message": error_msg, + "query": query, + "total_results": 0, + "results": [] + } + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + print(f"[query_text_search] {error_msg}") + return { + "error": True, + "status_code": 500, + "message": error_msg, + "query": query, + "total_results": 0, + "results": [] + } + + +def query_database_with_metadata( + query: str = None, + top_k: int = 20, + filter_score: int = 0, + detail_filter_score: Optional[Dict[str, Any]] = None, + categories: List[str] = None, + organizations: List[str] = None, + resource_type: List[str] = None, + file_type: List[str] = None +): + """ + Query vector database with metadata filters for media search v2. + """ + url = f"{base_url}/api/documents/search" + print(f"[query_database_with_metadata] URL: {url}") + + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + + # Build request payload + data = { + "top_k": top_k, + "filter_score": filter_score, + "detail_filter_score": detail_filter_score + } + + # Add query only if provided + if query: + data["query"] = query + + # Add optional filters if provided + if categories: + data["categories"] = categories + if organizations: + data["organizations"] = organizations + if resource_type: + data["resource_type"] = resource_type + if file_type: + data["file_type"] = file_type + + print(f"[query_database_with_metadata] Request Data: {data}") + + try: + response = requests.post(url, json=data, headers=headers, timeout=30) + + if response.status_code == 200: + result = response.json() + print(f"[query_database_with_metadata] Success: Retrieved {len(result.get('results', []))} results") + return result + else: + error_msg = f"Error: {response.status_code}" + try: + error_detail = response.json() + error_msg += f" - {error_detail}" + except: + error_msg += f" - {response.text}" + + print(f"[query_database_with_metadata] {error_msg}") + return { + "error": True, + "status_code": response.status_code, + "message": error_msg, + "query": query, + "total_results": 0, + "top_k": top_k, + "results": [] + } + + except requests.exceptions.Timeout: + error_msg = "Request timeout - Vector database took too long to respond" + print(f"[query_database_with_metadata] {error_msg}") + return { + "error": True, + "status_code": 504, + "message": error_msg, + "query": query, + "total_results": 0, + "top_k": top_k, + "results": [] + } + + except requests.exceptions.ConnectionError: + error_msg = "Connection error - Unable to reach vector database" + print(f"[query_database_with_metadata] {error_msg}") + return { + "error": True, + "status_code": 503, + "message": error_msg, + "query": query, + "total_results": 0, + "top_k": top_k, + "results": [] + } + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + print(f"[query_database_with_metadata] {error_msg}") + return { + "error": True, + "status_code": 500, + "message": error_msg, + "query": query, + "total_results": 0, + "top_k": top_k, + "results": [] + } + + +def apply_prompt_template(question: str) -> str: + """ + A helper function that applies additional template on user's question. + Prompt engineering could be done here to improve the result. Here I will just use a minimal example. + """ + prompt = f""" + Based on the above data (if applicable) please answer to following question/greeting: + {question} + + REMEMBER STRICTLY DO NOT PROVIDE ANY INFORMATION WHICH IS OUTSIDE OF CONTEXT AVAILABLE TO YOU. + """ + return prompt + + +def call_bedrock_api(prompt, messages, temperature, company_bot, chunks: List[str]): + """ + Call chatgpt api with user's question and retrieved chunks. + """ + text_to_add = " Use the following chunks along with the other information provided to generate the output:\n" + prompt[0]['text'] += text_to_add + ''.join( + map(lambda chunk: f"\n{chunk}", chunks) + ) + print(messages) + + response = handle_bedrock_model( + system_prompt=prompt, messages=messages, max_token=2048, + temperature=temperature, company_bot=company_bot + ) + + return response + + +def ask(messages, user_question, temperature, priority_filter, top_k, prompt, filter_score, company_bot): + """ + Handle user's questions. + """ + chunks_response = query_database(query_prompt=user_question, priority_filter=priority_filter, limit=top_k) + print("chunks_response", chunks_response) + chunks = [] + if chunks_response and chunks_response["relevant_texts"]: + for result in chunks_response["relevant_texts"]: + print(f"relevance_score: {result['relevance_score']} filter_score: {filter_score}") + if ("qdrant_recommendation_text" in result and result["qdrant_recommendation_text"] is not None + and len(result["qdrant_recommendation_text"]) > 20 and result["relevance_score"] >= filter_score + ): + chunks.append(result["qdrant_recommendation_text"]) + + elif ("translated_text" in result and result["translated_text"] is not None + and len(result["translated_text"]) > 20): + chunks.append(result["translated_text"]) + print("\nCHUNKS: ", chunks) + chunks = [] + print("\nChunk Response: ", chunks_response) + response = call_bedrock_api( + prompt=prompt, messages=messages, temperature=temperature, chunks=chunks, company_bot=company_bot + ) + return response, chunks, chunks_response diff --git a/chatbot/utils/chat_utils.py b/chatbot/utils/chat_utils.py new file mode 100644 index 0000000..18ee7e5 --- /dev/null +++ b/chatbot/utils/chat_utils.py @@ -0,0 +1,154 @@ +from chatbot.models import Profile, LLMProvider, CompanyChat +import json + +def format_message_as_per_openai_format(chats, intro=None): + ai_user = Profile.objects.values("id").get(id=1) + if intro: + messages = [ + { + 'role': 'user', + 'content': "Hello" + }, { + 'role': 'assistant', + 'content': intro + } + ] + else: + messages = [] + for chat in chats: + chat_receiver = None + chat_message = None + chat_translated_message = None + + # variable instialisation + if isinstance(chat, CompanyChat): + chat_receiver = getattr(chat.receiver, 'id', None) + chat_message = getattr(chat, 'message', None) + chat_translated_message = getattr(chat, 'translated_message', None) + + elif isinstance(chat, dict): + chat_receiver = chat.get("receiver") + chat_message = chat.get("message") + chat_translated_message = chat.get("translated_message") + + if chat_receiver == ai_user.get("id"): + user_message = chat_message + if chat_translated_message is not None and chat_translated_message != '': + user_message = chat_translated_message + messages.append({ + 'role': 'user', + 'content': user_message + }) + else: + messages.append({ + 'role': 'assistant', + "content": chat_message + }) + return messages + + +def format_message_as_per_bedrock_format(chats, intro=None, other_info=None): + ai_user = Profile.objects.values("id").get(id=1) + if intro: + if other_info: + user_name = other_info.get('first_name', None) + user_location = other_info.get('user_location', None) + if user_name: + initial_msg = f"Hello my name is {user_name}." + if user_location: + initial_msg += f" I am from {user_location}" + else: + initial_msg = "Hello" + else: + initial_msg = "Hello" + messages = [ + { + 'role': 'user', + 'content': [{'text': initial_msg}] + }, { + 'role': 'assistant', + 'content': [{'text': intro}] + } + ] + else: + messages = [] + for chat in chats: + chat_receiver = None + chat_message = None + chat_translated_message = None + + # variable instialisation + if isinstance(chat, CompanyChat): + chat_receiver = getattr(chat.receiver, 'id', None) + chat_message = getattr(chat, 'message', None) + chat_translated_message = getattr(chat, 'translated_message', None) + + elif isinstance(chat, dict): + chat_receiver = chat.get("receiver") + chat_message = chat.get("message") + chat_translated_message = chat.get("translated_message") + + if chat_receiver == ai_user.get("id"): + user_message = chat_message + if chat_translated_message is not None and chat_translated_message != '': + user_message = chat_translated_message + messages.append({ + 'role': 'user', + 'content': [{'text': user_message}] + }) + else: + messages.append({ + 'role': 'assistant', + "content": [{'text': chat_message}] + }) + + if not messages or messages[0].get('role') != 'user': + messages.insert(0, { + 'role': 'user', + 'content': [{'text': 'Hello'}] + }) + + return messages + + +def get_guided_chat(company_bot, company_chats, intro=None, other_info=None): + messages = [] + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + messages = format_message_as_per_bedrock_format(chats=company_chats, intro=intro, other_info=other_info) + elif company_bot.provider == LLMProvider.OPENAI: + messages = format_message_as_per_openai_format(chats=company_chats, intro=intro) + + return messages + + +def convert_llama_to_openai_tool(llama_tool_call): + try: + tool_spec = llama_tool_call.get("toolConfig", {}).get("tools", [])[0].get("toolSpec", {}) + + if not tool_spec: + raise ValueError("Invalid toolSpec structure in the provided Llama tool call.") + + parameters = tool_spec.get("inputSchema", {}).get("json", {}) + + if "properties" in parameters: + for key, value in parameters["properties"].items(): + if value.get("type") == "array" and "items" not in value: + value["items"] = {"type": "string"} + + openai_tool = [ + { + "type": "function", + "function": { + "name": tool_spec.get("name"), + "description": tool_spec.get("description"), + "parameters": parameters + } + } + ] + + print("Converted OpenAI tool:", json.dumps(openai_tool, indent=4)) + return openai_tool + + except Exception as e: + print("Error converting tool:", str(e)) + return None diff --git a/chatbot/utils/chaupal_question.py b/chatbot/utils/chaupal_question.py new file mode 100644 index 0000000..32082a1 --- /dev/null +++ b/chatbot/utils/chaupal_question.py @@ -0,0 +1,65 @@ +from channels.layers import get_channel_layer +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, LLMProvider, CompanyBot +import logging +from chatbot.utils.one_shot_utils import get_assistant_prompt + + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + + +def get_chaupal_challenge_response(messages): + + response = None + company_bot = CompanyBot.objects.get(route='/chaupal_challenges') + challenge_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=company_bot.context) + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=challenge_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=challenge_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + is_json_response=False + ) + + print("response_body bedrock: ", response) + + return response + + +def get_chaupal_solution_response(messages): + + response = None + company_bot = CompanyBot.objects.get(route='/chaupal_solutions') + challenge_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=company_bot.context) + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=challenge_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=challenge_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + is_json_response=False + ) + + print("response_body bedrock: ", response) + + return response diff --git a/chatbot/utils/chaupal_tool_call.py b/chatbot/utils/chaupal_tool_call.py new file mode 100644 index 0000000..8de4e87 --- /dev/null +++ b/chatbot/utils/chaupal_tool_call.py @@ -0,0 +1,135 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, LLMProvider +from chatbot.models.company_models import CompanyStateMachine +import logging + +from chatbot.utils.chaupal_question import get_chaupal_challenge_response, get_chaupal_solution_response +from chatbot.utils.shiksha_chaupal.checker_utils import prepare_missing_stage_questions + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + + +def get_chaupal_tool_call_response( + system_prompt, messages, company_bot, session_id, channel_name, route, profile_id, profile, skip_llm_call +): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + chunks = [] + + response = None + if not skip_llm_call: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + tools = [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + response = handle_openai_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tools, tool_choice='auto', is_json_response=False + ) + + print("response_body bedrock: ", response) + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + print("Response: ", response) + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + else: + is_function_call = True + if is_function_call: + bot_question = "" + print("its func call") + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + print("Statemachine we using: ", state_machine.name, " with step: ", state_machine.step) + bot_question = state_machine.bot_question + + if state_machine.name == 'CHALLENGES': + challenge_res = get_chaupal_challenge_response(messages=messages) + if challenge_res and isinstance(challenge_res, str): + bot_question = challenge_res + else: + bot_question = 'I am sorry, I could not understood completely. Could you rephrase this please?' + if state_machine.name == 'SOLUTIONS': + solution_res = get_chaupal_solution_response(messages=messages) + if solution_res in ["", '', '""', None]: + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + bot_question = state_machine.bot_question + elif solution_res and isinstance(solution_res, str): + bot_question = solution_res + else: + bot_question = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + print("In function call: so asking bot question as: ", bot_question) + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=bot_question, + chunks=chunks, status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + return response + else: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + print("its not a func call") + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=response, + chunks=chunks, status=ChatStatus.IN_PROGRESS, translated_message=translated_message, + stage=state_machine.name + ) + + return response diff --git a/chatbot/utils/company_bot.py b/chatbot/utils/company_bot.py new file mode 100644 index 0000000..19346e9 --- /dev/null +++ b/chatbot/utils/company_bot.py @@ -0,0 +1,15 @@ +import logging +from chatbot.models import CompanyBot + +logger = logging.getLogger('django') + + +def get_company_bot(route: str, profile=None) -> CompanyBot | None: + try: + if profile: + return CompanyBot.objects.filter(company=profile.company, route=route).first() + return CompanyBot.objects.filter(route=route).first() + except Exception as e: + logger.error("Error during company bot retrieval: %s", e, exc_info=True) + return None + \ No newline at end of file diff --git a/chatbot/utils/database_util.py b/chatbot/utils/database_util.py new file mode 100644 index 0000000..7949e3f --- /dev/null +++ b/chatbot/utils/database_util.py @@ -0,0 +1,159 @@ +import json +from datetime import datetime + +import requests +import os +DATABASE_INTERFACE_BEARER_TOKEN = os.getenv('DATABASE_INTERFACE_BEARER_TOKEN') + +SEARCH_TOP_K = 3 + +base_url = os.getenv('VECTOR_DB_BASE_URL') + + +def upsert_single_file(filename, file, metadata, media): + url = f"{base_url}/api/documents" + print("url: ", url) + if isinstance(metadata, dict): + metadata_json = json.dumps(metadata) + else: + metadata_json = metadata + + # Build payload with new format + payload = { + 'metadata': metadata_json, + 'source_id': str(media.id), + 'priority': media.priority + } + + # Add company_id if available + if hasattr(media, 'organization') and media.organization: + payload['company_id'] = media.organization.slug + + # Add title if available (from KeyValue or media name) + title = None + if hasattr(media, 'key_values'): + from chatbot.models import KeyValue + title_kv = KeyValue.objects.filter(media=media, key__iexact='TITLE').first() + if title_kv: + title = title_kv.value + if not title: + title = media.name + if title: + payload['title'] = title + + # Add summary if available (from description) + if hasattr(media, 'description') and media.description: + payload['summary'] = media.description + + # Add tags if available + if hasattr(media, 'tags'): + tags_list = list(media.tags.values_list('name', flat=True)) + if tags_list: + payload['tags'] = json.dumps(tags_list) + + files = [ + ('file', (filename, file, media.media_type)) + ] + headers = { + 'accept': 'application/json', + } + print("payload: ", payload) + + try: + response = requests.request( + "POST", url, headers=headers, data=payload, + files=files, timeout=300 + ) + + print(f"Response status code: {response.status_code}") + + try: + response_json = response.json() + print("upserted: ", response_json) + return response.status_code, json.dumps(response_json) + except requests.exceptions.JSONDecodeError: + print(f"Non-JSON response received. Status: {response.status_code}") + print(f"Response headers: {response.headers}") + print(f"Response content preview: {response.text[:500] if response.text else 'Empty'}") + + return response.status_code, response.text + + except requests.exceptions.Timeout: + print(f"Request timeout after 60 seconds for media ID: {media.id}") + return 504, "Request timeout" + except requests.exceptions.ConnectionError as e: + print(f"Connection error for media ID {media.id}: {str(e)}") + return 503, f"Connection error: {str(e)}" + except Exception as e: + print(f"Unexpected error for media ID {media.id}: {str(e)}") + return 500, f"Unexpected error: {str(e)}" + + +def delete_single_file(media_id, company_slug=None): + url = f"{base_url}/api/documents/{media_id}" + + params = {'company_id': company_slug} if company_slug else {} + + headers = { + 'accept': 'application/json', + } + response = requests.request("DELETE", url, headers=headers, params=params) + print("deleted: ", response.json()) + return response.status_code, response.text + + +def update_single_file(media_id, filename, file, metadata, media): + """Update a file in the vector database by deleting and re-uploading""" + url = f"{base_url}/api/documents/{media_id}" + + if isinstance(metadata, dict): + metadata_json = json.dumps(metadata) + else: + metadata_json = metadata + + # Add updated_at timestamp + if isinstance(metadata, dict): + metadata['updated_at'] = str(datetime.now()) + metadata_json = json.dumps(metadata) + + payload = { + 'metadata': metadata_json, + 'priority': media.priority + } + + if hasattr(media, 'organization') and media.organization: + payload['company_id'] = media.organization.slug + + # Add title if available (from KeyValue or media name) + title = None + if hasattr(media, 'key_values'): + from chatbot.models import KeyValue + title_kv = KeyValue.objects.filter(media=media, key__iexact='TITLE').first() + if title_kv: + title = title_kv.value + if not title: + title = media.name + if title: + payload['title'] = title + + # Add summary if available (from description) + if hasattr(media, 'description') and media.description: + payload['summary'] = media.description + + # Add tags if available + if hasattr(media, 'tags'): + tags_list = list(media.tags.values_list('name', flat=True)) + if tags_list: + payload['tags'] = json.dumps(tags_list) + + files = [ + ('file', (filename, file, media.media_type)) + ] + headers = { + 'accept': 'application/json', + } + + print("update payload: ", payload) + response = requests.request("PUT", url, headers=headers, data=payload, files=files) + print("updated: ", response.json()) + return response.status_code, response.text diff --git a/chatbot/utils/elevate/profile_utils.py b/chatbot/utils/elevate/profile_utils.py new file mode 100644 index 0000000..d4a5e6d --- /dev/null +++ b/chatbot/utils/elevate/profile_utils.py @@ -0,0 +1,114 @@ +import os +import requests +from chatbot.models import Profile, Company, SessionFlowName +from chatbot.models.geo_models import ProfileAddress +import json_repair + +elevate_base_url = os.getenv('ELEVATE_BASE_URL') + +def handle_elevate_profile(access_token): + try: + url = f"{elevate_base_url}/user/v1/user/read" + headers = { + 'X-auth-token': access_token + } + response = requests.get(url=url, headers=headers) + print("Read status: ", response.status_code) + response.raise_for_status() + + json_data = response.json() + print(json_data) + + if json_data.get('responseCode', '').lower() != 'ok': + print("Unexpected response code:", json_data.get('responseCode')) + return {} + + user_data = json_data.get('result', {}) + full_name = user_data.get('name', '') + first_name, last_name = (full_name.split(' ', 1) + [''])[:2] + phone = user_data.get('phone') + email = user_data.get('email') + language = user_data.get('preferred_language') + raw_designation = user_data.get('professional_role') + designation_value = None + if isinstance(raw_designation, dict): + designation_value = raw_designation.get('label') or raw_designation + elif isinstance(raw_designation, str): + try: + designation_value = json_repair.repair_json(raw_designation, return_objects=True) + if isinstance(designation_value, dict): + designation_value = designation_value.get('label') + except Exception: + designation_value = raw_designation + else: + designation_value = None + + if language: + if isinstance(language, dict): + language = language.get('value', 'en') + else: + language = user_data.get('preferred_language') + else: + language = 'en' + + company = Company.objects.filter(slug='shikshalokamstaging').first() + + if (not email or email == '') and phone and phone != '': + email = f"{phone}@shikshalokam.org" + + if not email or email == '': + print("No valid email or phone found to generate email.") + return {} + + profile, _ = Profile.objects.update_or_create( + email=email, + defaults={ + 'first_name': first_name, + 'last_name': last_name, + 'phone': user_data.get('phone'), + 'status': user_data.get('status', 'ACTIVE'), + 'company': company, + 'password': "grit@123", + 'latest_flow_used': SessionFlowName.LoginMiStory, + 'location': user_data.get('location'), + 'designation': designation_value, + 'other_params': {'elevate_profile_details': user_data}, + 'source': 'elevate', + 'preferred_route': language, + } + ) + + state = user_data.get('state', {}) + district = user_data.get('district', {}) + block = user_data.get('block', {}) + + if state.get('label') or district.get('label') or block.get('label'): + ProfileAddress.objects.update_or_create( + profile=profile, + defaults={ + 'state': state.get('label'), + 'district': district.get('label'), + 'block': block.get('label'), + } + ) + print("ProfileAddress updated or created successfully") + + profile_address = ProfileAddress.objects.filter(profile=profile).first() + + profile_response = { + "first_name": profile.first_name, + "company": profile.company.slug if profile.company else None, + "state": profile_address.state if profile_address else None, + "has_accepted_tnc": "ONGOING", + "route": profile.preferred_route, + "profileid": profile.id, + 'reroute_url': os.getenv('SSO_REROUTE_URL') + } + return profile_response + + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + except Exception as e: + print(f"Unexpected error: {e}") + + return {} diff --git a/chatbot/utils/elevate/project_detail.py b/chatbot/utils/elevate/project_detail.py new file mode 100644 index 0000000..5d1366e --- /dev/null +++ b/chatbot/utils/elevate/project_detail.py @@ -0,0 +1,33 @@ +import os +import requests +import traceback + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def fetch_existing_project_attachments(project_id, access_token): + try: + url = f"https://{base_url}/userProjects/details/{project_id}" + print("Fetching attachments from URL:", url) + + headers = { + "X-auth-token": access_token, + "Content-Type": "application/json", + } + + response = requests.post(url, headers=headers) + response.raise_for_status() + + response_json = response.json() + print("Project detail response received.") + + story = response_json.get("result", {}).get("story", {}) + existing_attachments = story.get("attachments", []) + + print("Existing attachments:", existing_attachments) + return existing_attachments + + except Exception as e: + print(f"Failed to fetch existing attachments: {str(e)}") + traceback.print_exc() + return [] diff --git a/chatbot/utils/env_parser.py b/chatbot/utils/env_parser.py new file mode 100644 index 0000000..0650aa6 --- /dev/null +++ b/chatbot/utils/env_parser.py @@ -0,0 +1,12 @@ +def load_env_to_dict(value: str | None) -> dict: + if value is None: + return {} + env_dict = {} + env_lines = value.split("\n") + for line in env_lines: + line = line.strip() + # Ignore empty lines and comments + if line and not line.startswith("#"): + key, value = line.split("=", 1) + env_dict[key.strip()] = value.strip().strip('"').strip("'") + return env_dict diff --git a/chatbot/utils/gotenberg_utils.py b/chatbot/utils/gotenberg_utils.py new file mode 100644 index 0000000..3ca3536 --- /dev/null +++ b/chatbot/utils/gotenberg_utils.py @@ -0,0 +1,34 @@ +import os +import requests + + +gotenberg_url = os.getenv("GOTENBERG_URL") + + +def generate_pdf_with_gotenberg(html_content): + + files = { + "files": ("index.html", html_content, "text/html"), + } + + data = { + "marginTop": "0cm", + "marginBottom": "0cm", + "marginLeft": "0cm", + "marginRight": "0cm", + "paperWidth": "210mm", + "paperHeight": "297mm", + "printBackground": "true", + } + + try: + response = requests.post(gotenberg_url, files=files, data=data) + + if response.status_code == 200: + pdf = response.content + return pdf + else: + return None + except Exception as e: + print("Error: ", e) + return None diff --git a/chatbot/utils/guided_guest_tool_call.py b/chatbot/utils/guided_guest_tool_call.py new file mode 100644 index 0000000..60ca14f --- /dev/null +++ b/chatbot/utils/guided_guest_tool_call.py @@ -0,0 +1,110 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, CompanyChat, LLMProvider +from chatbot.models.company_models import CompanyStateMachine +import logging + + +logger = logging.getLogger('django') +channel_layer = get_channel_layer() + + +def get_guided_guest_tool_call_response( + system_prompt, messages, company_bot, session_id, channel_name, route, profile_id +): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + chunks = [] + + response = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + tools = [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + response = handle_openai_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tools, tool_choice='auto', is_json_response=False + ) + + print("response_body bedrock: ", response) + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + print("Response: ", response) + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + if is_function_call: + print("its func call") + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=bot_question, + chunks=chunks, status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + return response + else: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + print("its not a func call") + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=response, + chunks=chunks, status=ChatStatus.IN_PROGRESS, translated_message=translated_message, + stage=state_machine.name + ) + + return response diff --git a/chatbot/utils/image_converter.py b/chatbot/utils/image_converter.py new file mode 100644 index 0000000..808bb2c --- /dev/null +++ b/chatbot/utils/image_converter.py @@ -0,0 +1,38 @@ +import os +import io +from django.http import JsonResponse, HttpResponse +from PIL import Image, UnidentifiedImageError +from django.views.decorators.csrf import csrf_exempt + + +@csrf_exempt +def convert_image(request): + if request.method != 'POST': + return JsonResponse({'error': 'Only POST method allowed'}, status=405) + + image_file = request.FILES.get('image') + if not image_file: + return JsonResponse({'error': 'No image provided'}, status=400) + + try: + # Try opening the image (supports HEIF via pillow-heif) + image = Image.open(image_file) + output_io = io.BytesIO() + + # Convert to JPEG + image.save(output_io, format='JPEG') + output_io.seek(0) + + # Create new filename + original_name = os.path.splitext(image_file.name)[0] + output_filename = f"{original_name}.jpg" + + # Return as downloadable JPEG + response = HttpResponse(output_io, content_type='image/jpeg') + response['Content-Disposition'] = f'attachment; filename="{output_filename}"' + return response + + except UnidentifiedImageError: + return JsonResponse({'error': 'Unidentified image file'}, status=400) + except Exception as e: + return JsonResponse({'error': f'Conversion failed: {str(e)}'}, status=500) diff --git a/chatbot/utils/kafka_utils.py b/chatbot/utils/kafka_utils.py new file mode 100644 index 0000000..973210b --- /dev/null +++ b/chatbot/utils/kafka_utils.py @@ -0,0 +1,83 @@ +import json + +from chatbot.models import Profile +from shikshalokam.models import Project, Category, ProjectTemplate, Task +from django.db import transaction + + +def update_project_in_db(project_data): + if project_data: + project_id = project_data.get("_id") + if project_id: + try: + with transaction.atomic(): + current_project = Project.objects.get(project_id=project_id) + + current_project.project_status = project_data.get('status', current_project.project_status) + current_project.categories = json.dumps(project_data.get('categories', current_project.categories)) + current_project.template_id = project_data.get('projectTemplateId', current_project.template_id) + current_project.recommended_for = json.dumps(project_data.get('recommendedFor', + current_project.recommended_for)) + current_project.actual_title = project_data.get('title', current_project.actual_title) + current_project.description = project_data.get('description', current_project.description) + current_project.program_id = project_data.get('programId', current_project.program_id) + current_project.program_name = project_data.get( + 'programInformation', {} + ).get("name", current_project.program_name) + + current_project.save() + + tasks = project_data.get("tasks") + if tasks: + for task_data in tasks: + task_id = task_data.get("_id") + if task_id: + Task.objects.update_or_create( + task_id=task_id, + project=current_project, + defaults={ + 'task_name': task_data.get("name"), + 'task_status': task_data.get("status"), + 'description': task_data.get("description"), + 'source': json.dumps(task_data.get("source")), + } + ) + + print("Project, and tasks updated successfully.") + except Project.DoesNotExist: + print(f"Project with ID {project_id} does not exist.") + except Exception as e: + print(f"An error occurred: {str(e)}") + + +def update_profile_in_db(profile_data, user_id): + if not user_id or not profile_data: + return + try: + current_profile = Profile.objects.get(userid=user_id) + + if profile_data and current_profile: + current_profile.email = profile_data.get('email', current_profile.email) + current_profile.first_name = profile_data.get('name', current_profile.first_name) + print("email: ", profile_data.get('email')) + print("first_name: ", profile_data.get('name')) + + preferred_language = profile_data.get('preferred_language', {}).get('value') + if preferred_language: + print("preferred_language: ", preferred_language) + if not current_profile.other_params: + current_profile.other_params = {} + current_profile.other_params['preferred_language'] = preferred_language + + current_profile.org_associated = profile_data.get('organization', {}).get( + 'name', current_profile.org_associated) + current_profile.designation = json.dumps(profile_data.get('user_roles', current_profile.designation)) + print("organization: ", profile_data.get('organization', {}).get('name')) + print("designation: ", json.dumps(profile_data.get('user_roles'))) + + current_profile.save() + + except Profile.DoesNotExist: + print(f"Profile with ID {user_id} does not exist.") + except Exception as e: + print(f"An error occurred: {str(e)}") diff --git a/chatbot/utils/knowledge_service/auto_tag_utils.py b/chatbot/utils/knowledge_service/auto_tag_utils.py new file mode 100644 index 0000000..d0493a2 --- /dev/null +++ b/chatbot/utils/knowledge_service/auto_tag_utils.py @@ -0,0 +1,162 @@ +import json +import os +from chatbot.celery_tasks.knowledge_service.tag_tasks import get_auto_extracted_data +from chatbot.models import Tag, TagChoices, TagSourceChoices + +S3_BASE_URL = os.getenv('S3_MEDIA_URL') +BOT_PROFILE_ID = 1 + + +def save_auto_tags(media): + """ + Generate and save auto tags for the given media. + """ + auto_tag_names = get_auto_extracted_data(media) # currently returns [] + tag_objs = [] + company = getattr(media.company_bot, 'company', None) + + for name in auto_tag_names: + tag_obj, created = Tag.objects.get_or_create( + name=name, + company=company, + defaults = { + 'created_by_id': 1, + 'status': TagChoices.APPROVED + } + ) + if not created and not tag_obj.status: + tag_obj.status = TagChoices.APPROVED + tag_obj.save() + + tag_objs.append(tag_obj) + + if tag_objs: + # Add auto tags to the media, keeping existing tags + media.tags.add(*tag_objs) + print(f"Saved auto tags for media {media.id}: {[t.name for t in tag_objs]}") + else: + print(f"No auto tags generated for media {media.id}") + + +class TagProcessor: + + @staticmethod + def get_master_tags(company=None, other_params=None, include_description=False): + try: + if other_params: + try: + if isinstance(other_params, str): + params = json.loads(other_params) + else: + params = other_params + + include_description = params.get('include_description', include_description) + except (json.JSONDecodeError, TypeError): + pass + + query = Tag.objects.filter( + source_type__in=[TagSourceChoices.MANUAL, TagSourceChoices.AI_EXTRACTED], + status=TagChoices.APPROVED + ) + + # if company: + # query = query.filter(company=company) + + if include_description: + return [ + { + 'name': tag['name'], + 'description': tag['description'] or '' + } + for tag in query.values('name', 'description').distinct() + ] + else: + return list(query.values_list('name', flat=True).distinct()) + + except Exception as e: + print(f"Error getting master tags: {e}") + return [] + + @staticmethod + def process_tags_for_media(tag_names, tag_source, user_profile, company, is_manual=True): + """Process tags and create/update tag objects""" + tags = [] + + for tag_name in tag_names: + if isinstance(tag_name, dict): + tag_text = tag_name.get('text', '') + source = tag_name.get('source', tag_source) + description = tag_name.get('description', '') + else: + tag_text = tag_name + source = tag_source + description = '' + + # Clean tag name + if tag_text.startswith('auto-'): + clean_tag_name = tag_text.replace('auto-', '') + else: + clean_tag_name = tag_text + + if is_manual: + tag, created = Tag.objects.get_or_create( + name=clean_tag_name, + defaults={ + 'created_by': user_profile, + 'company': company, + 'status': TagChoices.APPROVED, + 'source_type': TagSourceChoices.MANUAL, + 'description': '' + } + ) + if not created and tag.source_type == TagSourceChoices.MANUAL: + tag.status = TagChoices.APPROVED + tag.save() + else: + # Auto tags + if source == 'extracted': + source_type = TagSourceChoices.AI_EXTRACTED + status = TagChoices.APPROVED + desc_to_save = '' + else: + source_type = TagSourceChoices.AI_GENERATED + status = TagChoices.PENDING + desc_to_save = description + + tag, created = Tag.objects.get_or_create( + name=clean_tag_name, + defaults={ + 'created_by_id': BOT_PROFILE_ID, + 'company': company, + 'status': status, + 'source_type': source_type, + 'description': desc_to_save + } + ) + + tags.append(tag) + + return tags + + @staticmethod + def extract_tag_texts(tags_data): + """Extract just the text from tags for subdocuments""" + texts = [] + for tag in tags_data: + if isinstance(tag, dict) and 'text' in tag: + texts.append(tag['text']) + elif isinstance(tag, str): + texts.append(tag) + return texts + + @staticmethod + def process_tags(tags_data): + """Process tags into consistent format""" + processed_tags = [] + for tag in tags_data: + if isinstance(tag, dict): + processed_tags.append(tag) + else: + processed_tags.append({'text': tag, 'source': 'extracted'}) + return processed_tags + diff --git a/chatbot/utils/knowledge_service/base/extraction_config.py b/chatbot/utils/knowledge_service/base/extraction_config.py new file mode 100644 index 0000000..f91408d --- /dev/null +++ b/chatbot/utils/knowledge_service/base/extraction_config.py @@ -0,0 +1,107 @@ +import logging +from typing import Dict, Any + +# Set up logging +logger = logging.getLogger('django') + +# Default configuration constants +MAX_DEPTH = 1 +DEFAULT_MAX_SUBDOCS = 10 +DEFAULT_MAIN_DOC_MAX_CHARS = 3000 +DEFAULT_SUBDOC_MAX_CHARS = 500 +DEFAULT_EXCEL_MAX_ROWS = 50 +DEFAULT_EXCEL_MAX_COLS = 20 +DEFAULT_MAX_FILE_SIZE_MB = 50 + +# URL patterns for document extraction +GOOGLE_DOC_PATTERNS = [ + 'docs.google.com/document', + 'drive.google.com/file', + 'docs.google.com/spreadsheets', + 'docs.google.com/forms', + 'docs.google.com/presentation' +] + +# Excluded domains for URL extraction +EXCLUDED_DOMAINS = [ + 'googleusercontent.com', + 'gstatic.com', + 'chrome.google.com', + 'googleapis.com', + 'youtube.com', + 'twitter.com', + 'forms.google.com' +] + +# HTTP headers for document downloads +DEFAULT_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1' +} + +# Google Drive access denial patterns +ACCESS_DENIED_PATTERNS = [ + 'You need access', + 'Request access', + 'Access denied', + 'Permission denied', + 'Sign in to continue', + 'This file is private', + 'Sorry, unable to open', + 'Google Accounts', + 'docs.google.com/forms' +] + + +def parse_extractor_config(company_bot) -> Dict[str, Any]: + """Parse DocumentExtractor configuration from company_bot's other_params + + Args: + company_bot: Company bot instance with configuration + + Returns: + Dictionary with extractor configuration + """ + extractor_config = { + 'max_depth': MAX_DEPTH, + 'max_subdocs': DEFAULT_MAX_SUBDOCS, + 'enable_ocr': True, + 'compress_images': True, + 'extract_images': False, + 'main_doc_max_chars': DEFAULT_MAIN_DOC_MAX_CHARS, + 'subdoc_max_chars': DEFAULT_SUBDOC_MAX_CHARS, + 'excel_max_rows': DEFAULT_EXCEL_MAX_ROWS, + 'excel_max_cols': DEFAULT_EXCEL_MAX_COLS, + 'max_file_size_mb': DEFAULT_MAX_FILE_SIZE_MB, + } + + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + import json + import json_repair + + other_params = json_repair.repair_json(company_bot.other_params, return_objects=True) if isinstance( + company_bot.other_params, str) else company_bot.other_params + + # Extract DocumentExtractor configuration + if other_params: + extractor_config.update({ + 'max_depth': other_params.get('max_depth', MAX_DEPTH), + 'max_subdocs': other_params.get('max_subdocs', DEFAULT_MAX_SUBDOCS), + 'enable_ocr': other_params.get('enable_ocr', True), + 'compress_images': other_params.get('compress_images', True), + 'extract_images': other_params.get('extract_images', False), + 'main_doc_max_chars': other_params.get('main_doc_max_chars', DEFAULT_MAIN_DOC_MAX_CHARS), + 'subdoc_max_chars': other_params.get('subdoc_max_chars', DEFAULT_SUBDOC_MAX_CHARS), + 'excel_max_rows': other_params.get('excel_max_rows', DEFAULT_EXCEL_MAX_ROWS), + 'excel_max_cols': other_params.get('excel_max_cols', DEFAULT_EXCEL_MAX_COLS), + 'max_file_size_mb': other_params.get('max_file_size_mb', DEFAULT_MAX_FILE_SIZE_MB), + }) + except Exception as e: + logger.error(f"Error parsing other_params: {e}") + + return extractor_config \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/base/extraction_utils.py b/chatbot/utils/knowledge_service/base/extraction_utils.py new file mode 100644 index 0000000..aa8318b --- /dev/null +++ b/chatbot/utils/knowledge_service/base/extraction_utils.py @@ -0,0 +1,168 @@ +import re +import logging +from typing import List, Optional +from urllib.parse import urlparse +from chatbot.models import FileTypeChoices + +logger = logging.getLogger('django') + + +def normalize_url_for_tracking(url: str) -> str: + """ + Normalize URL for deduplication tracking + """ + try: + # Remove trailing slashes + normalized = url.rstrip('/') + + # For Google Docs/Sheets, normalize parameters + if 'docs.google.com' in normalized: + # Extract the document/sheet ID + if '/d/' in normalized: + doc_id = normalized.split('/d/')[1].split('/')[0] + + if 'spreadsheets' in normalized: + # For spreadsheets, ignore gid parameter + base = f"https://docs.google.com/spreadsheets/d/{doc_id}" + elif 'document' in normalized: + base = f"https://docs.google.com/document/d/{doc_id}" + elif 'forms' in normalized: + base = f"https://docs.google.com/forms/d/{doc_id}" + else: + base = normalized.split('?')[0].split('#')[0] + + return base + + # For other URLs, remove query parameters for normalization + return normalized.split('?')[0].split('#')[0] + + except Exception as e: + logger.error(f"Error normalizing URL {url}: {e}") + return url + + +def determine_media_type_from_url(url: str) -> Optional[str]: + """ + Determine media type from URL + """ + try: + parsed_url = urlparse(url) + path = parsed_url.path.lower() + + # Extract extension if present + if '.' in path: + extension = path.rsplit('.', 1)[-1] + + # Check if it's a valid extension first + if not FileTypeChoices.is_valid_extension(extension): + logger.warning(f"Invalid extension {extension} in URL {url}") + return None # Return None for invalid extensions + + # Use the existing method instead of hardcoding + mime_type = FileTypeChoices.get_mime_from_extension(extension) + if mime_type: + return mime_type.value + else: + # Extension is valid but not mapped - default to TXT + logger.warning(f"No MIME type mapping for valid extension {extension}") + return FileTypeChoices.TXT.value + + # No extension found - default to TXT + return FileTypeChoices.TXT.value + + except Exception as e: + logger.error(f"Error determining media type from URL {url}: {e}") + return FileTypeChoices.TXT.value + + +def convert_google_drive_url(url: str) -> str: + """ + Convert Google URLs to downloadable formats - includes spreadsheets and forms + """ + try: + if 'docs.google.com/document' in url: + if '/d/' in url: + doc_id = url.split('/d/')[1].split('/')[0] + return f"https://docs.google.com/document/d/{doc_id}/export?format=docx" + + elif 'drive.google.com/file' in url: + if '/d/' in url: + file_id = url.split('/d/')[1].split('/')[0] + return f"https://drive.google.com/uc?id={file_id}&export=download" + + elif 'docs.google.com/spreadsheets' in url: + if '/d/' in url: + sheet_id = url.split('/d/')[1].split('/')[0] + # Remove any gid parameter for export + return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=xlsx" + + elif 'docs.google.com/forms' in url: + # Google Forms can't be downloaded as documents + # Return the URL as-is, it will be handled as non-downloadable + logger.info(f"Google Form detected, cannot convert to downloadable format: {url}") + return url + + return url + except Exception as e: + logger.error(f"Error converting Google Drive URL: {e}") + return url + + +def get_comprehensive_content_for_url_extraction(document_text: str, other_data: dict = None) -> str: + """ + Get comprehensive content for URL extraction from the original file + """ + try: + # If we have the comprehensive content stored in other_data, use it + if other_data and 'comprehensive_text_for_urls' in other_data: + comprehensive_text = other_data['comprehensive_text_for_urls'] + logger.info(f"Using stored comprehensive content: {len(comprehensive_text)} chars") + return comprehensive_text + + # Fallback to the document_text if no comprehensive content available + logger.info(f"No comprehensive content available, using document text: {len(document_text)} chars") + return document_text + + except Exception as e: + logger.error(f"Error getting comprehensive content: {e}") + return document_text + + +def find_explicit_tag_sections(document_text: str) -> List[str]: + """ + Find explicit tag/classification sections in the document + """ + try: + tag_sections = [] + + # Common patterns for explicit tag/classification sections + tag_patterns = [ + r'(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):\s*([^\n\r]+)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n([^\n\r]+(?:\n[^\n\r]+)*?)(?=\n\n|\n[A-Z]|\n\s*$|$)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n((?:\s*[-•*]\s*[^\n\r]+\n?)+)', + r'(?:^|\n)(?:tags?|keywords?|categories|classification|subject areas?|topics?|themes?):?\s*\n((?:\s*\d+\.\s*[^\n\r]+\n?)+)', + ] + + doc_lower = document_text.lower() + + for pattern in tag_patterns: + matches = re.finditer(pattern, doc_lower, re.MULTILINE | re.IGNORECASE) + for match in matches: + section_content = match.group(1).strip() + if section_content and len(section_content) > 2: + tag_sections.append(section_content) + + # Remove duplicates while preserving order + unique_sections = [] + seen_content = set() + for section in tag_sections: + section_key = section.lower().strip() + if section_key not in seen_content: + unique_sections.append(section) + seen_content.add(section_key) + + return unique_sections + + except Exception as e: + logger.error(f"Error finding explicit tag sections: {e}") + return [] \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/base/main.py b/chatbot/utils/knowledge_service/base/main.py new file mode 100644 index 0000000..794214f --- /dev/null +++ b/chatbot/utils/knowledge_service/base/main.py @@ -0,0 +1,265 @@ +"""Main module providing the public API for document extraction""" + +import json +import logging +from typing import Dict, Any +from chatbot.utils.knowledge_service.extractor.document_extractor import DocumentExtractor +from chatbot.utils.knowledge_service.base.extraction_config import parse_extractor_config + +logger = logging.getLogger('django') + + +def extract_tags_from_document_url(url: str, company_bot) -> Dict[str, Any]: + """ + Extract structured information from document URL + """ + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [], + "media_type": None + } + + try: + # Parse configuration from company_bot + extractor_config = parse_extractor_config(company_bot) + + # Create extractor instance + extractor = DocumentExtractor(**extractor_config) + + # Process document + result = extractor.process_document_from_url(url, company_bot) + return result + + except Exception as e: + logger.error(f"Error extracting tags from URL: {e}") + return default_response + + +def extract_tags_from_document_file(file, company_bot, file_extension, other_data): + """ + Extract structured information from document file + """ + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [], + "media_type": None + } + + try: + # Parse configuration from company_bot + extractor_config = parse_extractor_config(company_bot) + + # Validate file size + max_file_size_mb = extractor_config.get('max_file_size_mb', 50) + max_file_size_bytes = max_file_size_mb * 1024 * 1024 + file_size = 0 + + if hasattr(file, 'size'): + file_size = file.size + elif hasattr(file, 'seek') and hasattr(file, 'tell'): + current_position = file.tell() + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(current_position) + + if file_size > max_file_size_bytes: + file_size_mb = file_size / (1024 * 1024) + error_msg = (f"File size ({file_size_mb:.2f} MB) exceeds the maximum allowed size " + f"of {max_file_size_mb} MB. Please reduce the file size.") + logger.error(error_msg) + raise ValueError(error_msg) + + # Create extractor instance + extractor = DocumentExtractor(**extractor_config) + + # Extract both limited and comprehensive content + document_text, extracted_images, comprehensive_text_for_urls, media_type = extractor.extract_text_from_file( + file, file_extension + ) + + if not document_text: + return default_response + + # Pass comprehensive content in other_data + if not other_data: + other_data = {} + other_data['comprehensive_text_for_urls'] = comprehensive_text_for_urls + + # Extract information using Bedrock with URL processing + result = extractor.extract_with_llm( + document_text, company_bot, extracted_images=extracted_images, other_data=other_data + ) + + if result and not result.get('media_type'): + print(f"Assigning media_type {media_type} to result") + result['media_type'] = media_type + + return result + + except ValueError as ve: + error_message = str(ve) + logger.error(f"Processing error: {error_message}") + + # Return error response instead of default_response + error_response = { + "error": error_message, + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [], + "media_type": None + } + + # Distinguish between different types of ValueError + if "file size" in error_message.lower() or "exceeds the maximum allowed size" in error_message.lower(): + # File size validation error + error_response["error_type"] = "file_size_exceeded" + elif "llm returned" in error_message.lower() or "unexpected list format" in error_message.lower() or "plain text instead" in error_message.lower(): + # LLM response format error + error_response["error_type"] = "llm_response_format_error" + elif "failed to extract title" in error_message.lower(): + # Title extraction error + error_response["error_type"] = "title_extraction_error" + elif "processing failed with unexpected error" in error_message.lower(): + # LLM processing error + error_response["error_type"] = "llm_processing_error" + else: + # Generic validation error + error_response["error_type"] = "validation_error" + + return error_response + + except Exception as e: + # Handle any other unexpected errors + error_message = f"Unexpected error during document processing: {str(e)}" + logger.error(error_message) + return { + "error": error_message, + "error_type": "unexpected_error", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [] + } + + +def get_doc_tags_from_ai(file, company_bot, file_extension, other_data): + """ + Main entry point for document processing with improved error handling + """ + try: + result = extract_tags_from_document_file(file, company_bot, file_extension, other_data) + print("Final result: ", result) + logger.info("Final Extraction Result:\n%s", json.dumps(result, indent=2, ensure_ascii=False)) + return result + + except ValueError as ve: + error_message = str(ve) + logger.error(f"Processing error: {error_message}") + + # Common error response for all AI processing failures + if any(keyword in error_message.lower() for keyword in [ + "ai processing failed", "unable to extract structured data", + "llm returned", "unexpected", "processing failed" + ]): + return { + "error": "AI processing failed - unable to extract structured data from document. Please try uploading the file again.", + "error_type": "ai_processing_failed", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [] + } + elif "file size" in error_message.lower() or "exceeds the maximum allowed size" in error_message.lower(): + # File size validation error + return { + "error": error_message, + "error_type": "file_size_exceeded", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [] + } + else: + # Generic validation error + return { + "error": f"Document processing failed: {error_message}", + "error_type": "validation_error", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [] + } + + except Exception as e: + # Handle any other unexpected errors + error_message = "AI processing failed - unable to extract structured data from document. Please try uploading the file again." + logger.error(f"Unexpected error during document processing: {str(e)}") + return { + "error": error_message, + "error_type": "ai_processing_failed", + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], + "images": [] + } \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/base_task_utils.py b/chatbot/utils/knowledge_service/base_task_utils.py new file mode 100644 index 0000000..efbf23e --- /dev/null +++ b/chatbot/utils/knowledge_service/base_task_utils.py @@ -0,0 +1,114 @@ +import os +from chatbot.models import FileTypeChoices +import hashlib +import requests + + +def determine_media_type_from_url(source_url, parent_media=None): + """ + Determine media type and filename from URL response. + """ + response = None + try: + # Download the source document + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } + + # Convert Google URLs to downloadable format + download_url = source_url + if 'docs.google.com/document' in source_url and '/d/' in source_url: + doc_id = source_url.split('/d/')[1].split('/')[0] + download_url = f"https://docs.google.com/document/d/{doc_id}/export?format=docx" + elif 'docs.google.com/spreadsheets' in source_url and '/d/' in source_url: + sheet_id = source_url.split('/d/')[1].split('/')[0] + download_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=xlsx" + elif 'drive.google.com/file/d/' in source_url: + file_id = source_url.split('/d/')[1].split('/')[0] + download_url = f"https://drive.google.com/uc?export=download&id={file_id}" + print(f"Converting Google Drive URL: {source_url} -> {download_url}") + + response = requests.get(download_url, headers=headers, timeout=30, allow_redirects=True) + response.raise_for_status() + + # Default values + if parent_media: + parent_name_without_ext = os.path.splitext(parent_media.name)[0] + else: + parent_name_without_ext = "document" + + url_hash = hashlib.md5(source_url.encode()).hexdigest()[:6] + filename = f"linked_doc_{parent_name_without_ext}_{url_hash}" + media_type = FileTypeChoices.TXT.value + + # Check content type first + content_type = response.headers.get('content-type', '').lower() + + # FIXED: Determine file type from content-type header (after proper download URL) + print(f"Content-Type: {content_type}") + + # Detect file type from content-type header + if 'application/pdf' in content_type: + media_type = FileTypeChoices.PDF.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.pdf" + elif 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' in content_type: + media_type = FileTypeChoices.DOCX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.docx" + elif 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in content_type: + media_type = FileTypeChoices.XLSX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.xlsx" + elif 'application/msword' in content_type: + media_type = FileTypeChoices.DOC.value if hasattr(FileTypeChoices, 'DOC') else FileTypeChoices.DOCX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.doc" + elif 'application/vnd.ms-excel' in content_type: + media_type = FileTypeChoices.XLS.value if hasattr(FileTypeChoices, 'XLS') else FileTypeChoices.XLSX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.xls" + elif 'text/plain' in content_type: + media_type = FileTypeChoices.TXT.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.txt" + elif 'text/csv' in content_type: + media_type = FileTypeChoices.CSV.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.csv" + elif 'docs.google.com' in source_url: + # Fallback for Google Docs URLs + if 'document' in source_url: + media_type = FileTypeChoices.DOCX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.docx" + elif 'spreadsheets' in source_url: + media_type = FileTypeChoices.XLSX.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.xlsx" + else: + # Try content-disposition header first + content_disposition = response.headers.get('content-disposition') + if content_disposition: + import re + matches = re.findall('filename="?([^"]+)"?', content_disposition) + if matches: + filename = matches[0] + # Get extension and determine type + ext = os.path.splitext(filename)[1].lower().strip('.') + if ext: + media_type = FileTypeChoices.get_mime_from_extension(ext) or FileTypeChoices.TXT.value + else: + # Filename without extension, try content-type + if 'application/pdf' in content_type: + media_type = FileTypeChoices.PDF.value + filename = f"{filename}.pdf" + else: + media_type = FileTypeChoices.TXT.value + filename = f"{filename}.txt" + else: + # Fallback: Default to PDF for unknown Google Drive files (most common) + if 'drive.google.com' in source_url: + media_type = FileTypeChoices.PDF.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.pdf" + else: + media_type = FileTypeChoices.TXT.value + filename = f"source_doc_{parent_name_without_ext}_{url_hash}.txt" + except Exception as e: + print("error while determining media_type") + return None, None, response + + print(f"Determined media_type: {media_type}, filename: {filename}") + return media_type, filename, response diff --git a/chatbot/utils/knowledge_service/cache_manager.py b/chatbot/utils/knowledge_service/cache_manager.py new file mode 100644 index 0000000..a4c5eac --- /dev/null +++ b/chatbot/utils/knowledge_service/cache_manager.py @@ -0,0 +1,344 @@ +import traceback +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse +from django.views import View +from chatbot.models import Profile, FileTypeChoices +import json +from django.core.cache import cache +from django.conf import settings + + +CACHE_TIMEOUT = getattr(settings, 'BATCH_UPLOAD_CACHE_TIMEOUT', 7200) + + +class CacheManager: + """Centralized cache management for batch upload""" + + @staticmethod + def get_cache_key(session_id, item_type, item_id): + """Generate consistent cache keys with proper sanitization""" + import re + + # Sanitize all components to ensure memcached compatibility + sanitized_session_id = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(session_id)) + sanitized_item_type = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(item_type)) + sanitized_item_id = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(item_id)) + + # Remove multiple consecutive underscores + sanitized_session_id = re.sub(r'_+', '_', sanitized_session_id) + sanitized_item_type = re.sub(r'_+', '_', sanitized_item_type) + sanitized_item_id = re.sub(r'_+', '_', sanitized_item_id) + + # Generate the cache key + cache_key = f"batch_upload_{sanitized_session_id}_{sanitized_item_type}_{sanitized_item_id}" + + # Final length check + if len(cache_key) > 240: + import hashlib + key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest() + cache_key = f"batch_upload_{sanitized_session_id}_{sanitized_item_type}_{key_hash[:16]}" + + # Final sanitization pass + cache_key = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key) + + return cache_key + + @staticmethod + def cache_file(file, session_id, file_index): + """Cache uploaded file content with sanitized cache key""" + try: + import re + import hashlib + + file_content = b'' + for chunk in file.chunks(): + file_content += chunk + + # More aggressive sanitization for memcached compatibility + # Remove all non-alphanumeric characters except dots, hyphens, underscores + sanitized_name = re.sub(r'[^a-zA-Z0-9\-_.]', '_', file.name) + # Remove multiple consecutive underscores + sanitized_name = re.sub(r'_+', '_', sanitized_name) + # Remove leading/trailing underscores + sanitized_name = sanitized_name.strip('_') + # Ensure reasonable length (memcached has 250 char limit for keys) + if len(sanitized_name) > 30: + # Keep first 30 chars and add hash of full name for uniqueness + name_hash = hashlib.md5(file.name.encode('utf-8')).hexdigest()[:8] + sanitized_name = sanitized_name[:22] + '_' + name_hash + + # Generate cache key with additional validation + cache_key_suffix = f"{file_index}_{sanitized_name}" + # Ensure the final cache key component doesn't have problematic characters + cache_key_suffix = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key_suffix) + + cache_key = CacheManager.get_cache_key(session_id, 'file', cache_key_suffix) + + # Additional validation: ensure cache key is memcached compatible + # Total length should be under 250 chars and contain only safe characters + if len(cache_key) > 240: # Leave some buffer + # If still too long, use a hash-based approach + key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest() + cache_key = f"batch_upload_{session_id}_file_{file_index}_{key_hash[:16]}" + + # Final validation - ensure only safe characters + cache_key = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key) + + cache_data = { + 'content': file_content, + 'name': file.name, # Keep original name + 'size': file.size, + 'type': 'file', + 'file_index': file_index + } + + cache.set(cache_key, cache_data, timeout=CACHE_TIMEOUT) + print(f"Cached file: {cache_key} (original: {file.name})") + return cache_key + except Exception as e: + print(f"Error caching file {file.name}: {e}") + import traceback + traceback.print_exc() + return None + + @staticmethod + def cache_subdocument(subdoc_data, session_id, parent_index, subdoc_path): + """Cache subdocument data for retry purposes - with all fields""" + try: + cache_key = CacheManager.get_cache_key(session_id, 'subdoc', f"{parent_index}_{subdoc_path}") + + # Ensure all subdocument fields are included + complete_subdoc_data = { + 'title': subdoc_data.get('title', ''), + 'summary': subdoc_data.get('summary', ''), + 'description': subdoc_data.get('description', subdoc_data.get('summary', '')), + 'media_type': subdoc_data.get('media_type', FileTypeChoices.TXT.value), + 'priority': subdoc_data.get('priority', 'P1'), + 'extracted_text': subdoc_data.get('extracted_text', subdoc_data.get('exact_content', '')), + 'exact_content': subdoc_data.get('exact_content', ''), + 'organization': subdoc_data.get('organization', ''), + 'document_type': subdoc_data.get('document_type', ''), + 'key_entities': subdoc_data.get('key_entities', []), + 'manual_tags': subdoc_data.get('manual_tags', []), + 'auto_tags': subdoc_data.get('auto_tags', []), + 'tags': subdoc_data.get('tags', []), + 'key_values': subdoc_data.get('key_values', []), + 'images': subdoc_data.get('images', []), + 'subdocument': subdoc_data.get('subdocument', []), + 'url': subdoc_data.get('url', []) + } + + cache_data = { + 'data': complete_subdoc_data, + 'parent_index': parent_index, + 'path': subdoc_path, + 'type': 'subdocument' + } + + cache.set(cache_key, cache_data, timeout=CACHE_TIMEOUT) + print(f"Cached subdocument: {cache_key} with data: {complete_subdoc_data.get('title', 'No title')}") + return cache_key + except Exception as e: + print(f"Error caching subdocument: {e}") + traceback.print_exc() + return None + + @staticmethod + def get_cached_item(cache_key): + """Retrieve item from cache with Redis-specific debugging""" + import time + from django.core.cache import cache + + max_retries = 2 + retry_delay = 0.1 + + for attempt in range(max_retries + 1): + try: + # Add timing to detect slow Redis responses + start_time = time.time() + cached_data = cache.get(cache_key) + response_time = time.time() - start_time + + if cached_data: + print(f"✓ Cache HIT: {cache_key} (attempt {attempt + 1}, {response_time:.3f}s)") + return cached_data + else: + print(f"✗ Cache MISS: {cache_key} (attempt {attempt + 1}, {response_time:.3f}s)") + + # For Redis, try to get connection info + try: + from django.core.cache import cache + if hasattr(cache, '_cache') and hasattr(cache._cache, 'get_client'): + redis_client = cache._cache.get_client() + connection_info = redis_client.connection_pool.connection_kwargs + print(f"Redis connection: {connection_info.get('host')}:{connection_info.get('port')}") + + # Check Redis connection + redis_client.ping() + print("Redis ping successful") + + # Check if key actually exists + exists = redis_client.exists(cache_key) + print(f"Redis key exists check: {exists}") + + except Exception as redis_debug_error: + print(f"Redis debug error: {redis_debug_error}") + + # If not last attempt, wait and retry + if attempt < max_retries: + print(f"Retrying cache get in {retry_delay}s...") + time.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + continue + else: + return None + + except Exception as e: + print(f"Cache retrieval error for {cache_key} (attempt {attempt + 1}): {e}") + if attempt < max_retries: + time.sleep(retry_delay) + retry_delay *= 2 + continue + else: + return None + + return None + + @staticmethod + def extend_cache_timeout(cache_keys, additional_timeout=None): + """Extend cache timeout for failed items""" + timeout = additional_timeout or CACHE_TIMEOUT + for cache_key in cache_keys: + cached_item = cache.get(cache_key) + if cached_item: + cache.set(cache_key, cached_item, timeout=timeout) + print(f"Extended cache timeout for: {cache_key}") + + +@method_decorator(staff_member_required, name='dispatch') +class GetCachedItemView(View): + """API endpoint to retrieve cached items""" + + def post(self, request): + try: + self._source_doc_cache = {} + data = json.loads(request.body) + company_bot_id = data.get('company_bot_id') + media_items = data.get('items', []) + session_id = data.get('session_id') + + results = [] + stats = { + 'total': len(media_items), + 'successful': 0, + 'failed': 0, + 'partial_success': 0, + 'timeouts': 0, + 'similarity_failures': 0 + } + + # Get current user's profile + try: + user_profile = Profile.objects.get(email=request.user.email) + except Profile.DoesNotExist: + user_profile = None + + print(f"Starting batch save for {len(media_items)} files") + + # Process each file with fault tolerance + for i, item_data in enumerate(media_items): + filename = item_data.get('filename', f'File_{i}') + print(f"Processing file {i + 1}/{len(media_items)}: {filename}") + + try: + bypass_similarity = item_data.get('bypass_similarity', False) + + # CRITICAL FIX: Use the file_index from item_data, not the loop index + # The file_index in item_data corresponds to the actual index used during caching + actual_file_index = item_data.get('file_index', i) + print(f"Using file_index {actual_file_index} for {filename} (loop index: {i})") + + # Ensure the item_data has the correct file_index for cache lookup + item_data['file_index'] = actual_file_index + + result = self.save_single_item_with_vector_db_wait_safe( + item_data=item_data, + company_bot_id=company_bot_id, + user_profile=user_profile, + session_id=session_id, + bypass_similarity=bypass_similarity + ) + + # Track statistics + if result['success']: + stats['successful'] += 1 + else: + stats['failed'] += 1 + if result.get('partial_success'): + stats['partial_success'] += 1 + if result.get('error_type') in ['VECTOR_DB_TIMEOUT', 'WAIT_ERROR']: + stats['timeouts'] += 1 + if result.get('error_type') == 'SIMILARITY_CHECK_FAILED': + stats['similarity_failures'] += 1 + + results.append(result) + print( + f"File {i + 1} result: {'✓' if result['success'] else '✗'} - {result.get('message', 'No message')}") + + except Exception as item_error: + print(f"Critical error processing {filename}: {item_error}") + stats['failed'] += 1 + + # Use the actual file_index for error reporting too + actual_file_index = item_data.get('file_index', i) + + results.append({ + 'success': False, + 'filename': filename, + 'message': f'Critical processing error: {str(item_error)}', + 'error_type': 'CRITICAL_ERROR', + 'file_index': actual_file_index, + 'file_key': item_data.get('file_key'), + 'session_id': session_id, + 'vector_db_saved': False + }) + + # Preserve cache for failed files + failed_cache_keys = [] + for r in results: + if not r['success'] and r.get('file_key'): + failed_cache_keys.append(r['file_key']) + # Also preserve cache for failed subdocuments + if r.get('subdocument_results'): + for subdoc_result in r['subdocument_results']: + if not subdoc_result.get('success') and subdoc_result.get('cache_key'): + failed_cache_keys.append(subdoc_result['cache_key']) + + if failed_cache_keys: + CacheManager.extend_cache_timeout(failed_cache_keys) + + # Generate summary message + summary_message = self.generate_batch_summary(stats) + print(f"Batch complete: {summary_message}") + + return JsonResponse({ + 'success': True, + 'results': results, + 'stats': stats, + 'summary_message': summary_message, + 'session_id': session_id + }) + + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Invalid JSON data' + }, status=400) + except Exception as batch_error: + print(f"Batch processing error: {batch_error}") + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': f'Batch processing failed: {str(batch_error)}' + }, status=500) \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/duplicate_detector.py b/chatbot/utils/knowledge_service/duplicate_detector.py new file mode 100644 index 0000000..17048e1 --- /dev/null +++ b/chatbot/utils/knowledge_service/duplicate_detector.py @@ -0,0 +1,107 @@ +import requests +import os +from chatbot.models import Media, CompanyBot + + +class DuplicateDetector: + """Comprehensive duplicate detection using trigram and vector similarity""" + + @staticmethod + def check_for_duplicates( + extracted_text, + company_slug, + exclude_media_id=None, + trigram_threshold=0.85, + semantic_threshold=0.85, + trigram_exact_threshold=0.95, + semantic_exact_threshold=0.90, + ): + """ + Check for duplicates using both trigram and semantic similarity + """ + if not extracted_text or len(extracted_text.strip()) < 50: + return + + # 1. First check trigram similarity (fast, local) + trigram_similar = Media.find_trigram_similar( + extracted_text=extracted_text, + company_slug=company_slug, + similarity_threshold=trigram_threshold, + exclude_id=exclude_media_id + ) + print("trigram_similar: ", trigram_similar) + + if trigram_similar and any(m['similarity'] >= trigram_exact_threshold for m in trigram_similar): + # Exact duplicate found + match = next(m for m in trigram_similar if m['similarity'] >= trigram_exact_threshold) + raise ValueError( + f"❌ EXACT DUPLICATE FOUND ({match['similarity'] * 100:.1f}% match):\n" + f" • '{match['name']}'\n\n" + f"This file appears to be an exact duplicate. Upload cancelled." + ) + + # 2. Check semantic similarity via vector service (if no exact match) + if not trigram_similar or all(m['similarity'] < trigram_exact_threshold for m in trigram_similar): + semantic_similar = DuplicateDetector.check_semantic_similarity( + text=extracted_text, + company_slug=company_slug, + threshold=semantic_threshold, + exclude_source_id=str(exclude_media_id) if exclude_media_id else None + ) + print("semantic_similar: ", semantic_similar) + # Only raise error if semantic similarity >= semantic_exact_threshold + if semantic_similar: + exact_semantic_matches = [ + doc for doc in semantic_similar + if doc['similarity_score'] >= semantic_exact_threshold + ] + if exact_semantic_matches: + error_parts = ["❌ DUPLICATE CONTENT DETECTED:"] + for doc in exact_semantic_matches[:3]: + error_parts.append( + f"({doc['similarity_score'] * 100:.1f}% similar)" + ) + error_parts.append("\nThis content appears to be a duplicate. Upload cancelled.") + raise ValueError("\n".join(error_parts)) + + # 3. Show near-duplicates as warning (but don't block) + # if trigram_similar and any(trigram_threshold <= m['similarity'] < trigram_exact_threshold for m in trigram_similar): + # print("⚠️ Warning: Near-duplicates found:") + # for match in [m for m in trigram_similar if trigram_threshold <= m['similarity'] < trigram_exact_threshold][:3]: + # print(f" • '{match['name']}' ({match['similarity'] * 100:.0f}% similar)") + + @staticmethod + def check_semantic_similarity(text, company_slug, threshold=0.85, exclude_source_id=None): + """Check semantic similarity via vector service API""" + try: + base_url = os.getenv('VECTOR_DB_BASE_URL') + url = f"{base_url}/api/documents/check-similarity" + + payload = { + "text": text, + "company_id": company_slug, + "threshold": threshold, + "exclude_source_id": exclude_source_id + } + + headers = { + 'Content-Type': 'application/json', + 'accept': 'application/json', + } + + response = requests.post(url, json=payload, headers=headers) + print("response: ", response) + if response.status_code == 200: + result = response.json() + print("response result: ", result) + if result.get('has_similar'): + return result.get('similar_documents', []) + else: + print(f"Vector service similarity check failed: {response.text}") + + return [] + + except Exception as e: + print(f"Error checking semantic similarity: {str(e)}") + # Don't fail the upload if vector service is down + return [] diff --git a/chatbot/utils/knowledge_service/extractor/document_extractor.py b/chatbot/utils/knowledge_service/extractor/document_extractor.py new file mode 100644 index 0000000..78495b6 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/document_extractor.py @@ -0,0 +1,850 @@ +"""Main DocumentExtractor class that coordinates all extraction functionality""" + +import io +import logging +import concurrent.futures +from threading import Lock +from typing import Dict, List, Any, Set, Tuple, Generator +from pathlib import Path +from urllib.parse import urlparse +from chatbot.models import FileTypeChoices +from chatbot.utils.knowledge_service.base.extraction_config import MAX_DEPTH +from .docx_extractor import DOCXExtractor +from .excel_extractor import ExcelExtractor +from .markdown_extractor import MarkdownExtractor +from .pdf_extractor import PDFExtractor +from .text_extractor import CSVExtractor, TXTExtractor +from .url_extractor import URLExtractor +from chatbot.utils.knowledge_service.processor.url_processor import DocumentURLProcessor +from chatbot.utils.knowledge_service.processor.image_processor import ImageProcessor +from chatbot.utils.knowledge_service.processor.ai_processor import AIContentProcessor +from chatbot.utils.knowledge_service.base.extraction_utils import ( + normalize_url_for_tracking, get_comprehensive_content_for_url_extraction, convert_google_drive_url +) + + +logger = logging.getLogger('django') + + +class DocumentExtractor: + """ + Extract structured content from documents using AWS Bedrock Llama model with enhanced features + """ + + def __init__( + self, max_depth: int = MAX_DEPTH, max_subdocs: int = 10, enable_ocr: bool = True, + compress_images: bool = True, extract_images: bool = False, main_doc_max_chars: int = 3000, + subdoc_max_chars: int = 500, excel_max_rows: int = 50, excel_max_cols: int = 20, + max_file_size_mb: int = 50, max_workers: int = 5 + ): + """Initialize with enhanced features and configurable limits""" + self.max_depth = max_depth + self.max_subdocs = max_subdocs + self.processed_urls: Set[str] = set() + self.url_cache: Dict[str, str] = {} + self.enable_ocr = enable_ocr + self.compress_images = compress_images + self.extract_images = extract_images # Control image extraction + + # Configurable text limits + self.main_doc_max_chars = main_doc_max_chars + self.subdoc_max_chars = subdoc_max_chars + self.excel_max_rows = excel_max_rows + self.excel_max_cols = excel_max_cols + + # File validation + self.allowed_extensions = {'.pdf', '.doc', '.docx', '.txt', '.csv', '.xls', '.xlsx'} + self.max_file_size_mb = max_file_size_mb + self.max_file_size_bytes = self.max_file_size_mb * 1024 * 1024 + + # Parallel processing + self.max_workers = max_workers + self.url_lock = Lock() + + # Initialize components + self.url_extractor = URLExtractor() + self.url_processor = DocumentURLProcessor(self.url_cache, max_file_size_mb) + self.image_processor = ImageProcessor(enable_ocr, compress_images, extract_images) + self.ai_processor = AIContentProcessor(main_doc_max_chars) + + # Initialize file extractors + self.pdf_extractor = PDFExtractor(self.image_processor) + self.docx_extractor = DOCXExtractor(self.image_processor) + self.excel_extractor = ExcelExtractor(excel_max_rows, excel_max_cols, subdoc_max_chars) # Legacy, kept for compatibility + self.markdown_extractor = MarkdownExtractor(subdoc_max_chars) # Used for Excel/CSV to Markdown conversion + self.csv_extractor = CSVExtractor(excel_max_rows, excel_max_cols) # Legacy, kept for compatibility + self.txt_extractor = TXTExtractor() + + def process_single_subdocument(self, sub_url: str, main_doc_url: str, + company_bot, other_data) -> Dict[str, Any]: + """Process a single subdocument - used for parallel processing""" + try: + # Extract content from subdocument URL + sub_text, sub_images, sub_media_type, sub_error_info, _ = self.extract_text_from_url( + sub_url, is_subdoc=True + ) + + if sub_error_info: + logger.info(f"Subdocument failed: {sub_error_info}") + + # Enhance error message for unsupported formats + if sub_error_info.get('error_type') == 'unsupported_format': + sub_error_info['error'] = f"Unsupported file format in linked document: {sub_error_info['error']}" + + return { + 'success': False, + 'error': { + "file_url": sub_url, + "error": sub_error_info, + "source_document": main_doc_url + } + } + + # Successfully accessed - process subdocument with LLM + if sub_text and len(sub_text.strip()) > 10: + # Get the downloadable URL + downloadable_url = convert_google_drive_url(sub_url) + + # Check if media type was determined + if sub_media_type is None: + logger.warning(f"Could not determine valid media type for {sub_url}") + return { + 'success': False, + 'error': { + "file_url": sub_url, + "error": { + 'error': 'Could not determine valid file type', + 'error_type': 'unknown_format', + 'url': sub_url + }, + "source_document": main_doc_url + } + } + + # Process subdocument content with Bedrock + subdoc_result = self.ai_processor.extract_basic_content( + sub_text, + company_bot, + sub_images, + other_data, + is_subdoc=True + ) + + # Check for any extraction errors in subdocument + if (subdoc_result.get('title_extraction_failed') or + subdoc_result.get('extraction_error') or + subdoc_result.get('error') or + subdoc_result.get('error_type')): + error_message = (subdoc_result.get('error') or + subdoc_result.get('extraction_error') or + 'LLM failed to extract title from subdocument') + + error_type = (subdoc_result.get('error_type') or + 'title_extraction_failed') + + logger.error(f"Subdocument extraction failed for {sub_url}: {error_message}") + return { + 'success': False, + 'error': { + "file_url": sub_url, + "error": { + 'error': error_message, + 'error_type': error_type, + 'url': sub_url + }, + "source_document": main_doc_url + } + } + + # Create subdocument entry (without "url" field) + subdoc_entry = { + "title": subdoc_result.get( + "title", + f"Document from {Path(urlparse(main_doc_url).path).name or 'linked document'}" + ), + "file_url": downloadable_url, + "media_type": sub_media_type, + "source_document": main_doc_url, + "exact_content": sub_text, + "summary": subdoc_result.get("summary", ""), + "tags": subdoc_result.get("tags", []), + "organization": subdoc_result.get("organization", ""), + "document_type": subdoc_result.get("document_type", ""), + "key_entities": subdoc_result.get("key_entities", []), + "subdocument": [], + "images": sub_images or [] + } + + return {'success': True, 'data': subdoc_entry} + else: + logger.warning(f"Subdocument {sub_url} has insufficient content") + return { + 'success': False, + 'error': { + "file_url": sub_url, + "error": { + 'error': 'Document has insufficient content (less than 10 characters)', + 'error_type': 'insufficient_content', + 'url': sub_url + }, + "source_document": main_doc_url + } + } + except Exception as e: + logger.error(f"Error processing subdocument {sub_url}: {str(e)}") + return { + 'success': False, + 'error': { + "file_url": sub_url, + "error": {"error": str(e), "error_type": "processing_error"}, + "source_document": main_doc_url + } + } + + def process_subdocuments_parallel(self, subdoc_urls: List[str], main_doc_url: str, + company_bot, other_data) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Process subdocuments in parallel""" + subdocuments = [] + failed_links = [] + + # Limit subdocuments to max_subdocs + urls_to_process = subdoc_urls[:self.max_subdocs] + + with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Submit all subdocument processing tasks + future_to_url = { + executor.submit( + self.process_single_subdocument, + url, main_doc_url, company_bot, other_data + ): url + for url in urls_to_process + } + + # Collect results as they complete + for future in concurrent.futures.as_completed(future_to_url): + url = future_to_url[future] + try: + result = future.result() + if result['success']: + subdocuments.append(result['data']) + else: + failed_links.append(result['error']) + except Exception as e: + logger.error(f"Exception processing subdocument {url}: {str(e)}") + failed_links.append({ + "file_url": url, + "error": {"error": str(e), "error_type": "processing_error"}, + "source_document": main_doc_url + }) + + return subdocuments, failed_links + + def extract_urls_memory_efficient(self, text: str) -> Generator[str, None, None]: + """Extract URLs using a memory-efficient generator approach""" + # Process text in chunks to avoid memory issues with large documents + chunk_size = 10000 # 10k characters per chunk + + for i in range(0, len(text), chunk_size): + chunk = text[i:i + chunk_size] + # Ensure we don't cut URLs in half - extend to next whitespace + if i + chunk_size < len(text): + next_space = text.find(' ', i + chunk_size) + if next_space != -1: + chunk = text[i:next_space] + + # Extract URLs from this chunk + urls = self.url_extractor.extract_urls_from_text(chunk) + for url in urls: + yield url + + def process_text_sections_generator(self, sections: List[str]) -> Generator[str, None, None]: + """Process text sections using a generator to save memory""" + for section in sections: + # Process section + processed = section.strip() + if processed: + yield processed + # Free memory after yielding + del section + + def _extract_content_from_bytes( + self, + content_bytes: bytes, + file_extension: str, + extract_mode: str = "full" + ) -> Tuple[str, List[Dict[str, Any]], str, List[str], Any]: + """Internal method to extract content from file bytes""" + file_extension = file_extension.lower().strip('.') + text = "" + images = [] + comprehensive_text = "" + hyperlinks = [] + media_type = None + + skip_text = extract_mode == "urls_only" + skip_urls = extract_mode == "limited" + + if file_extension == 'pdf': + if not skip_urls: + comprehensive_text, hyperlinks = self.pdf_extractor.extract_comprehensive_content_for_urls( + content_bytes) + + if not skip_text: + if extract_mode == "full": + text = comprehensive_text + else: + text = self.pdf_extractor.extract_text_enhanced(content_bytes) + + max_chars = self.subdoc_max_chars if extract_mode == "limited" else self.main_doc_max_chars + if len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated for LLM]" + + if self.extract_images: + images = self.image_processor.extract_images_from_pdf_pymupdf(content_bytes) + + media_type = FileTypeChoices.PDF + + elif file_extension in ['doc', 'docx']: + if not skip_urls: + comprehensive_text, hyperlinks = self.docx_extractor.extract_comprehensive_content_for_urls( + content_bytes) + + if not skip_text: + import tempfile + import os + import docx + + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + text = '\n'.join(self.process_text_sections_generator(text_parts)) + + max_chars = self.subdoc_max_chars if extract_mode == "limited" else self.main_doc_max_chars + if len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated for LLM]" + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + if self.extract_images: + images = self.image_processor.extract_images_from_docx(content_bytes) + + media_type = FileTypeChoices.DOCX + + elif file_extension in ['xls', 'xlsx']: + # Excel files are converted to Markdown format using MarkdownExtractor + # This provides better structured output for LLM processing compared to CSV or pandas string format + # The MarkdownExtractor uses tabulate library to create clean Markdown tables + filename = f"spreadsheet.{file_extension}" + + if not skip_urls: + # Extract comprehensive content for URL extraction (no character limits) + # Also extracts hyperlinks embedded in Excel cells using openpyxl + # Called when: extract_mode = "full" or "urls_only" + comprehensive_text, hyperlinks = self.markdown_extractor.extract_comprehensive_content_for_urls( + content_bytes, filename) + + if not skip_text: + # Extract limited content for LLM processing (with character limits) + # Converts Excel sheets to Markdown tables with proper formatting + # Called when: extract_mode = "full" or "limited" (subdocuments) + max_chars = self.subdoc_max_chars if extract_mode == "limited" else self.main_doc_max_chars + text = self.markdown_extractor.extract_limited_content(content_bytes, max_chars, filename) + + media_type = FileTypeChoices.XLSX + + elif file_extension == 'csv': + # Use MarkdownExtractor for better CSV to Markdown conversion + filename = "spreadsheet.csv" + + if not skip_urls: + # Extract comprehensive content for URL extraction (no character limits) + # Converts entire CSV to Markdown format for URL scanning + # Called when: extract_mode = "full" or "urls_only" + comprehensive_text, hyperlinks = self.markdown_extractor.extract_comprehensive_content_for_urls( + content_bytes, filename) + + if not skip_text: + # Extract limited content for LLM processing (with character limits) + # Converts CSV to Markdown table with proper formatting + # Called when: extract_mode = "full" or "limited" (subdocuments) + max_chars = self.subdoc_max_chars if extract_mode == "limited" else self.main_doc_max_chars + text = self.markdown_extractor.extract_limited_content(content_bytes, max_chars, filename) + + media_type = FileTypeChoices.CSV + + elif file_extension == 'txt': + if not skip_urls: + comprehensive_text, hyperlinks = self.txt_extractor.extract_comprehensive_content_for_urls( + content_bytes) + + if not skip_text: + text = content_bytes.decode('utf-8', errors='ignore') + + max_chars = self.subdoc_max_chars if extract_mode == "limited" else self.main_doc_max_chars + if len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated for LLM]" + + media_type = FileTypeChoices.TXT + + else: + text = content_bytes.decode('utf-8', errors='ignore') + comprehensive_text = text + + if hyperlinks: + comprehensive_text = comprehensive_text + "\n\n=== EXTRACTED HYPERLINKS ===\n" + "\n".join(hyperlinks) + + return text, images, comprehensive_text, hyperlinks, media_type + + def extract_text_from_url(self, url: str, is_subdoc: bool = False, is_source_doc: bool = False) -> Tuple[ + str, List[Dict[str, Any]], Any, Dict[str, Any], str]: + """Extract text content and images from document URL""" + + try: + # Download document + content_bytes, error_info, content_type = self.url_processor.download_document(url, is_subdoc) + + if error_info: + return "", [], None, error_info, "" + + if not content_bytes: + return "", [], None, None, "" + + is_pdf, is_excel, is_csv, is_docx, is_txt = self.url_processor.determine_file_type( + content_bytes, content_type, url + ) + + # Determine file extension + if is_pdf: + file_ext = 'pdf' + elif is_excel: + file_ext = 'xlsx' + elif is_csv: + file_ext = 'csv' + elif is_docx: + file_ext = 'docx' + else: + file_ext = 'txt' + + # Determine extraction mode + if is_source_doc: + extract_mode = "urls_only" + elif is_subdoc: + extract_mode = "limited" + else: + extract_mode = "full" + + # Use unified extraction method + text, images, full_text_for_url_extraction, extracted_hyperlinks, media_type = self._extract_content_from_bytes( + content_bytes, file_ext, extract_mode + ) + + # URL extraction logic (memory-efficient for large docs) + combined_urls = list(extracted_hyperlinks) + if len(full_text_for_url_extraction) > 50000: + seen_urls = set(combined_urls) + for url_found in self.extract_urls_memory_efficient(full_text_for_url_extraction): + if url_found not in seen_urls: + combined_urls.append(url_found) + seen_urls.add(url_found) + else: + text_urls = self.url_extractor.extract_urls_from_text(full_text_for_url_extraction) + for url_found in text_urls: + if url_found not in combined_urls: + combined_urls.append(url_found) + + # Logging + logger.info(f"URL extraction summary for {url}:") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + logger.info(f" - Total unique URLs: {len(combined_urls)}") + + # Apply character limit for subdocuments + if is_subdoc: + max_chars = self.subdoc_max_chars + if len(text) > max_chars: + text = text[:max_chars] + "\n...[Content truncated]" + + # Validation (skip for source docs) + if not is_source_doc and (not text or len(text.strip()) < 10): + logger.warning(f"No meaningful content extracted from {url}") + error_info = { + 'error': f'No content could be extracted from {url}', + 'error_type': 'no_content', + 'url': url + } + return "", [], None, error_info, "" + + if text: + self.url_cache[url] = text + + logger.info(f"Extracted {len(text)} chars, {len(images)} images from {url}") + + return text, images, media_type, None, full_text_for_url_extraction + + except Exception as e: + error_info = { + 'error': f'Failed to extract from {url}: {str(e)}', + 'error_type': 'extraction_error', + 'url': url + } + logger.error(f"Failed to extract from URL {url}: {e}") + return "", [], None, error_info, "" + + def extract_text_from_file(self, file, file_extension: str): + """Extract text content and images from various file types""" + + try: + file_extension = file_extension.lower().strip('.') + + # Read file bytes + if isinstance(file, (str, Path)): + file_path = Path(file) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + with open(file_path, 'rb') as f: + content_bytes = f.read() + else: + # File object + file.seek(0) + content_bytes = file.read() + if not isinstance(content_bytes, bytes): + content_bytes = content_bytes.encode('utf-8') + + # Use unified extraction method (full mode for local files) + text, images, comprehensive_text, hyperlinks, media_type = self._extract_content_from_bytes( + content_bytes, file_extension, extract_mode="full" + ) + + logger.info(f"Extracted from file: {len(text)} chars, {len(comprehensive_text)} comprehensive chars") + + return text, images, comprehensive_text, media_type + + except Exception as e: + logger.error(f"Error extracting from file: {e}") + return "", [], "" + + def read_document(self, file_path: str) -> str: + """Extract text content from various document formats""" + file_path = Path(file_path) + file_ext = file_path.suffix.lower().strip('.') + + try: + text, _, _, _ = self.extract_text_from_file(file_path, file_ext) + return text + except Exception as e: + raise Exception(f"Error reading document: {str(e)}") + + def process_document_with_links( + self, text: str, company_bot, comprehensive_text: str = None, processed_urls=None, + depth=0, max_depth=MAX_DEPTH, extracted_images: List[Dict[str, Any]] = None, other_data=None +) -> Dict[str, Any]: + """Process document and extract links from linked documents with enhanced URL extraction for ALL formats""" + if processed_urls is None: + processed_urls = set() + + try: + # Step 1: Extract basic content from current document using Bedrock + logger.info(f"{' ' * depth}Processing main document with Bedrock...") + main_result = self.ai_processor.extract_basic_content(text, company_bot, extracted_images, other_data) + + # Use comprehensive text for URL extraction + url_extraction_text = comprehensive_text if comprehensive_text else text + + # Step 2: Extract URLs from comprehensive document content + logger.info(f"{' ' * depth}Extracting URLs from main document...") + logger.info(f"{' ' * depth} - Using comprehensive content: {len(url_extraction_text)} chars") + logger.info(f"{' ' * depth} - Limited text for LLM: {len(text)} chars") + + # Memory-efficient URL extraction for large documents + if len(url_extraction_text) > 50000: + urls = list(self.extract_urls_memory_efficient(url_extraction_text)) + else: + urls = self.url_extractor.extract_urls_from_text(url_extraction_text) + + main_result["url"] = urls + + # Log all extracted URLs + logger.info(f"{' ' * depth}Total URLs extracted from main document: {len(urls)}") + + # Step 3: Process links + subdocuments = [] + failed_links = [] + source_documents = [] # NEW: List for source documents + + # Filter for document URLs + document_urls = [url for url in urls if self.url_extractor.is_document_url(url, depth)] + logger.info(f"{' ' * depth}Found {len(document_urls)} document URLs in main document") + + # Process first level of linked documents + first_level_results = [] + for main_doc_url in document_urls: + # Normalize URL for deduplication + normalized_url = normalize_url_for_tracking(main_doc_url) + + if normalized_url in processed_urls: + logger.info(f"{' ' * depth}Skipping already processed URL: {main_doc_url}") + continue + + logger.info(f"{' ' * depth}Processing linked document: {main_doc_url}") + processed_urls.add(normalized_url) + + # Extract content from this linked document + linked_text, linked_images, linked_media_type, error_info, full_text_for_urls = self.extract_text_from_url( + main_doc_url, is_source_doc=True + ) + + if error_info: + # Enhanced error handling for different error types + if error_info.get('error_type') == 'unsupported_format': + error_info['error'] = f"Unsupported file format: {error_info['error']}" + + failed_links.append({ + "file_url": main_doc_url, + "error": error_info, + "source_document": "main" + }) + continue + + if full_text_for_urls and len(full_text_for_urls.strip()) > 10: + # Check if media type was determined + if linked_media_type is None: + logger.warning(f"Could not determine valid media type for {main_doc_url}") + failed_links.append({ + "file_url": main_doc_url, + "error": { + 'error': 'Could not determine valid file type', + 'error_type': 'unknown_format', + 'url': main_doc_url + }, + "source_document": "main" + }) + continue + + # NEW: Add source document entry with URL and exact content + source_documents.append({ + "url": main_doc_url, + "exact_content": full_text_for_urls # Markdown content for Excel/CSV, full text for others + }) + + first_level_results.append({ + 'main_doc_url': main_doc_url, + 'linked_text': linked_text, + 'linked_images': linked_images, + 'linked_media_type': linked_media_type, + 'full_text_for_urls': full_text_for_urls + }) + else: + logger.warning(f"Linked document {main_doc_url} has insufficient content: {linked_text}") + failed_links.append({ + "file_url": main_doc_url, + "error": { + 'error': 'Document has insufficient content (less than 10 characters)', + 'error_type': 'insufficient_content', + 'url': main_doc_url + }, + "source_document": "main" + }) + + # Process subdocuments in parallel for each first-level document + for first_level_doc in first_level_results: + main_doc_url = first_level_doc['main_doc_url'] + full_text_for_urls = first_level_doc['full_text_for_urls'] + + # Extract URLs from the comprehensive content + logger.info(f"{' ' * depth}Extracting URLs from linked document: {main_doc_url}") + logger.info(f"{' ' * depth} - Using comprehensive content: {len(full_text_for_urls)} chars") + + # Memory-efficient URL extraction + if len(full_text_for_urls) > 50000: + links_in_subdoc = list(self.extract_urls_memory_efficient(full_text_for_urls)) + else: + links_in_subdoc = self.url_extractor.extract_urls_from_text(full_text_for_urls) + + logger.info(f"{' ' * depth}Found {len(links_in_subdoc)} total links inside {main_doc_url}") + + # Filter for document URLs + subdoc_document_urls = [url for url in links_in_subdoc if self.url_extractor.is_document_url(url, depth)] + logger.info(f"{' ' * depth}Found {len(subdoc_document_urls)} document URLs inside {main_doc_url}") + + # Filter out already processed URLs + urls_to_process = [] + for sub_url in subdoc_document_urls: + normalized_sub_url = normalize_url_for_tracking(sub_url) + + with self.url_lock: + if normalized_sub_url not in processed_urls: + processed_urls.add(normalized_sub_url) + urls_to_process.append(sub_url) + + # Process subdocuments in parallel + if urls_to_process: + logger.info(f"{' ' * depth}Processing {len(urls_to_process)} subdocuments in parallel...") + subdocs, failed = self.process_subdocuments_parallel( + urls_to_process, main_doc_url, company_bot, other_data + ) + subdocuments.extend(subdocs) + failed_links.extend(failed) + + main_result["subdocument"] = subdocuments + main_result["failed_links"] = failed_links + main_result["source_document"] = source_documents # NEW: Add source documents to result + + # Log summary + logger.info(f"{' ' * depth}Processing complete:") + logger.info(f"{' ' * depth} - URLs in main document: {len(urls)}") + logger.info(f"{' ' * depth} - Document URLs in main: {len(document_urls)}") + logger.info(f"{' ' * depth} - Source documents: {len(source_documents)}") # NEW + logger.info(f"{' ' * depth} - Successfully processed subdocuments: {len(subdocuments)}") + logger.info(f"{' ' * depth} - Failed: {len(failed_links)}") + logger.info(f"{' ' * depth} - Total URLs processed: {len(processed_urls)}") + + return main_result + + except ValueError as ve: + logger.error(f"Main document processing failed: {str(ve)}") + raise + except Exception as e: + logger.error(f"Error processing document: {str(e)}") + import traceback + traceback.print_exc() + return { + "title": "", + "organization": "", + "tags": [], + "exact_content": text, + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "failed_links": [], + "source_document": [], # NEW + "images": extracted_images or [] + } + + def extract_with_bedrock(self, document_text, company_bot, + extracted_images: List[Dict[str, Any]] = None, + other_data=None) -> Dict[str, Any]: + try: + logger.info("Starting document processing with recursive link extraction...") + + # Get comprehensive content for URL extraction + comprehensive_text_for_urls = get_comprehensive_content_for_url_extraction( + document_text, other_data + ) + + result = self.process_document_with_links( + text=document_text, # Limited text for LLM + comprehensive_text=comprehensive_text_for_urls, # Full text for URL extraction + company_bot=company_bot, + extracted_images=extracted_images, + other_data=other_data + ) + return result + except ValueError as ve: + # Re-raise ValueError so it can be handled by the calling function + logger.error(f"Document processing validation failed: {str(ve)}") + raise # This allows the error to propagate to get_doc_tags_from_ai() + except Exception as e: + logger.error(f"Document processing failed with unexpected error: {str(e)}") + return { + "title": "", + "organization": "", + "tags": [], + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "source_document": [], # NEW + "images": extracted_images or [] + } + + def extract_with_llm(self, text: str, company_bot=None, max_chars: int = 6000, + extracted_images: List[Dict[str, Any]] = None, other_data=None) -> Dict[str, Any]: + """Extract structured information using AWS Bedrock Llama""" + # Don't truncate - preserve complete content + return self.extract_with_bedrock( + document_text=text, company_bot=company_bot, + extracted_images=extracted_images, other_data=other_data + ) + + def process_document_from_url(self, url: str, company_bot=None) -> Dict[str, Any]: + """Process document directly from URL""" + try: + text_content, extracted_images, extracted_media_type, error_info, _ = self.extract_text_from_url( + url, is_subdoc=False + ) + + if error_info: + return { + "error": error_info['error'], + "error_type": error_info.get('error_type', 'unknown'), + "file_path": url, + "file_name": Path(url).name, + } + + if not text_content or len(text_content.strip()) < 10: + raise ValueError("Document appears to be empty or unreadable") + + extracted_info = self.extract_with_llm(text_content, company_bot, extracted_images=extracted_images) + + result = { + "file_path": url, + "file_name": Path(url).name, + "text_length": len(text_content), + **extracted_info + } + + return result + + except Exception as e: + return { + "error": str(e), + "file_path": url, + "file_name": "Unknown", + } + + def process_document(self, file_path: str, company_bot=None, other_data=None) -> Dict[str, Any]: + """Process document from file path""" + try: + # Read document content with enhanced extraction + text_content, extracted_images, comprehensive_text_for_urls, _ = self.extract_text_from_file( + file_path, Path(file_path).suffix.strip('.') + ) + + if not text_content or len(text_content.strip()) < 10: + raise ValueError("Document appears to be empty or unreadable") + + # Extract structured information using LLM + extracted_info = self.extract_with_llm( + text=text_content, + company_bot=company_bot, + extracted_images=extracted_images, + other_data=other_data, + ) + + # Add metadata + result = { + "file_path": str(file_path), + "file_name": Path(file_path).name, + "text_length": len(text_content), + **extracted_info + } + + return result + + except Exception as e: + return { + "error": str(e), + "file_path": str(file_path), + "file_name": Path(file_path).name if Path(file_path).exists() else "Unknown", + } \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/docx_extractor.py b/chatbot/utils/knowledge_service/extractor/docx_extractor.py new file mode 100644 index 0000000..857e330 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/docx_extractor.py @@ -0,0 +1,184 @@ +"""DOCX extraction functionality""" + +import io +import os +import logging +import tempfile +from typing import List, Dict, Any, Tuple +import docx + +logger = logging.getLogger('django') + + +class DOCXExtractor: + """Handles DOCX content extraction""" + + def __init__(self, image_processor): + self.image_processor = image_processor + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes) -> Tuple[str, List[str]]: + """Extract comprehensive DOCX content and hyperlinks + + Args: + content_bytes: DOCX file content as bytes + + Returns: + Tuple of (comprehensive_text, extracted_hyperlinks) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE DOCX CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + + # Extract all text content + text_parts = [] + extracted_hyperlinks = [] + + # Method 1: Extract from all relationships (most reliable) + logger.info("Extracting hyperlinks from document relationships...") + for rel_id, rel in doc.part.rels.items(): + if hasattr(rel, 'target_ref') and rel.target_ref and rel.target_ref.startswith('http'): + if rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found relationship hyperlink: {rel.target_ref}") + + # Method 2: Process paragraphs and extract hyperlinks from runs + logger.info("Processing paragraphs for content and hyperlinks...") + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + + # Extract hyperlinks from paragraph runs + for run in para.runs: + if hasattr(run, '_element'): + # Look for hyperlink elements in the XML + try: + hyperlinks = run._element.xpath('.//w:hyperlink', + namespaces={ + 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) + for hyperlink in hyperlinks: + r_id = hyperlink.get( + '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id') + if r_id and r_id in doc.part.rels: + try: + rel = doc.part.rels[r_id] + if hasattr(rel, + 'target_ref') and rel.target_ref and rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found paragraph hyperlink: {rel.target_ref}") + except: + continue + except Exception as e: + logger.debug(f"Error extracting hyperlinks from run: {e}") + continue + + # Method 3: Process tables + logger.info("Processing tables for content and hyperlinks...") + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text.strip(): + text_parts.append(f"[Table Cell]: {cell.text}") + + # Extract hyperlinks from table cells + for para in cell.paragraphs: + for run in para.runs: + if hasattr(run, '_element'): + try: + hyperlinks = run._element.xpath('.//w:hyperlink', + namespaces={ + 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) + for hyperlink in hyperlinks: + r_id = hyperlink.get( + '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id') + if r_id and r_id in doc.part.rels: + try: + rel = doc.part.rels[r_id] + if hasattr(rel, + 'target_ref') and rel.target_ref and rel.target_ref not in extracted_hyperlinks: + extracted_hyperlinks.append(rel.target_ref) + logger.info(f"Found table hyperlink: {rel.target_ref}") + except: + continue + except Exception as e: + logger.debug(f"Error extracting hyperlinks from table cell: {e}") + continue + + comprehensive_text = '\n'.join(text_parts) + + logger.info(f"DOCX extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + + if extracted_hyperlinks: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_hyperlinks[:10]): + logger.info(f" URL {i + 1}: {url}") + if len(extracted_hyperlinks) > 10: + logger.info(f" ... and {len(extracted_hyperlinks) - 10} more URLs") + + return comprehensive_text, extracted_hyperlinks + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error extracting comprehensive DOCX content: {e}") + # Fallback to basic text extraction + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts), [] + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + except: + return "", [] + + def extract_text(self, file_path) -> str: + """Extract text from Word document (file path) + + Args: + file_path: Path to DOCX file + + Returns: + Extracted text content + """ + doc = docx.Document(file_path) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts) + + def extract_text_from_object(self, file) -> str: + """Extract text from Word document (file object) + + Args: + file: DOCX file object + + Returns: + Extracted text content + """ + doc = docx.Document(file) + text_parts = [] + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + return '\n'.join(text_parts) \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/excel_extractor.py b/chatbot/utils/knowledge_service/extractor/excel_extractor.py new file mode 100644 index 0000000..f072c8d --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/excel_extractor.py @@ -0,0 +1,386 @@ +"""Excel extraction functionality""" + +import io +import logging +from typing import List, Dict, Any, Tuple +import pandas as pd + +logger = logging.getLogger('django') + +try: + import openpyxl + + HAS_OPENPYXL = True + logger.info("openpyxl available for enhanced Excel processing") +except ImportError: + HAS_OPENPYXL = False + logger.warning("openpyxl not available. Excel hyperlink extraction will be limited.") + + +class ExcelExtractor: + """Handles Excel content extraction""" + + def __init__(self, excel_max_rows: int = 50, excel_max_cols: int = 20, + subdoc_max_chars: int = 500): + self.excel_max_rows = excel_max_rows + self.excel_max_cols = excel_max_cols + self.subdoc_max_chars = subdoc_max_chars + + def extract_limited_content(self, content_bytes: bytes, max_chars: int = None) -> str: + """Extract limited content from Excel file for LLM processing + + Args: + content_bytes: Excel file content as bytes + max_chars: Maximum characters to extract + + Returns: + Limited text content for LLM processing + """ + if max_chars is None: + max_chars = self.subdoc_max_chars + + try: + excel_file = pd.ExcelFile(io.BytesIO(content_bytes)) + sheet_names = excel_file.sheet_names + + logger.info("=" * 80) + logger.info(f"EXCEL FILE CONTAINS {len(sheet_names)} SHEETS:") + for i, sheet_name in enumerate(sheet_names): + logger.info(f" Sheet {i + 1}: '{sheet_name}'") + logger.info("=" * 80) + + if not sheet_names: + return "" + + # Process sheets until we have enough content + all_text_parts = [] + total_chars = 0 + sheets_processed = 0 + + for sheet_idx, sheet_name in enumerate(sheet_names): + if total_chars >= max_chars: + break + + logger.info(f"Processing sheet {sheet_idx + 1}: '{sheet_name}'") + + try: + # Read the sheet + df = pd.read_excel( + excel_file, + sheet_name=sheet_name + ) + + # Skip empty sheets + if df.empty or len(df) == 0: + logger.warning(f"Sheet '{sheet_name}' is empty, moving to next sheet...") + continue + + # Limit rows and columns for processing + display_df = df.head(self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + display_df = display_df.iloc[:, :self.excel_max_cols] + + # Convert to CSV-like format + csv_string = display_df.to_csv(index=False) + + # Add sheet header if we're processing multiple sheets + if sheets_processed > 0: + all_text_parts.append(f"\n\n--- Sheet: '{sheet_name}' ---\n") + + all_text_parts.append(csv_string) + sheets_processed += 1 + + # Update total characters + current_text = '\n'.join(all_text_parts) + total_chars = len(current_text) + + logger.info(f"Sheet '{sheet_name}' added {len(csv_string)} chars (total: {total_chars} chars)") + + # Add truncation note for this sheet if needed + if len(df) > self.excel_max_rows or len(df.columns) > self.excel_max_cols: + all_text_parts.append( + f"\n[Sheet '{sheet_name}': Showing {min(len(df), self.excel_max_rows)} of {len(df)} rows, " + f"{min(len(df.columns), self.excel_max_cols)} of {len(df.columns)} columns]" + ) + + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}': {e}") + continue + + # If no sheets had data + if sheets_processed == 0: + logger.warning("All sheets are empty!") + return "All Excel sheets are empty (no data found)" + + # Join all parts + full_text = '\n'.join(all_text_parts) + original_length = len(full_text) + + logger.info(f"Processed {sheets_processed} sheets with data, extracted {original_length} chars") + + # Log the content + logger.info("=" * 80) + logger.info("EXCEL CONTENT BEING SENT TO LLM:") + logger.info("=" * 80) + logger.info(full_text) + logger.info("=" * 80) + + # Apply final character limit if needed + if len(full_text) > max_chars: + # Try to cut at a row boundary + lines = full_text.split('\n') + truncated_text = [] + current_length = 0 + + for line in lines: + if current_length + len(line) + 1 > max_chars - 50: + break + truncated_text.append(line) + current_length += len(line) + 1 + + full_text = '\n'.join(truncated_text) + "\n[Content truncated]" + logger.info(f"Excel content truncated from {original_length} to {len(full_text)} chars") + + return full_text + + except Exception as e: + logger.error(f"Error extracting Excel content: {e}") + return "" + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes) -> Tuple[str, List[str]]: + """Extract COMPLETE Excel content from ALL sheets and hyperlinks using openpyxl + + Args: + content_bytes: Excel file content as bytes + + Returns: + Tuple of (comprehensive_text, extracted_urls) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE EXCEL CONTENT FOR URL EXTRACTION (OPENPYXL)") + logger.info("=" * 80) + + if not HAS_OPENPYXL: + logger.warning("openpyxl not available, falling back to pandas method") + return self._extract_full_content_for_urls(content_bytes), [] + + wb = openpyxl.load_workbook(io.BytesIO(content_bytes), data_only=True) + sheet_names = wb.sheetnames + + logger.info(f"Processing ALL {len(sheet_names)} sheets for content and URL extraction:") + for i, sheet_name in enumerate(sheet_names): + logger.info(f" Sheet {i + 1}: '{sheet_name}'") + + all_text_parts = [] + extracted_urls = [] + total_urls_found = 0 + + # Process ALL sheets without any limits + for sheet_idx, sheet_name in enumerate(sheet_names): + logger.info( + f"Processing sheet {sheet_idx + 1}/{len(sheet_names)}: '{sheet_name}' for content and URLs...") + + try: + sheet = wb[sheet_name] + + # Check if sheet has data + if sheet.max_row == 1 and sheet.max_column == 1 and sheet.cell(1, 1).value is None: + logger.info(f" Sheet '{sheet_name}' is empty, skipping...") + continue + + logger.info(f" Sheet '{sheet_name}': {sheet.max_row} rows x {sheet.max_column} columns") + + # Extract content in multiple formats + sheet_text_parts = [] + sheet_urls = [] + + # Add sheet header + sheet_text_parts.append(f"\n=== SHEET: {sheet_name} ===") + + # Extract column headers (first row) + headers = [] + for col in range(1, sheet.max_column + 1): + cell = sheet.cell(1, col) + header_value = cell.value + if header_value is not None: + headers.append(str(header_value)) + else: + headers.append(f"Unnamed: {col - 1}") + + sheet_text_parts.append("COLUMNS: " + " | ".join(headers)) + + # Process each row + for row_idx in range(1, sheet.max_row + 1): + row_content = [] + row_has_content = False + + for col_idx in range(1, sheet.max_column + 1): + cell = sheet.cell(row_idx, col_idx) + + # Extract hyperlinks + if cell.hyperlink and cell.hyperlink.target: + url = cell.hyperlink.target + if url not in sheet_urls: + sheet_urls.append(url) + extracted_urls.append(url) + + # Extract cell content + cell_value = cell.value + if cell_value is not None: + cell_str = str(cell_value).strip() + if cell_str: + col_name = headers[col_idx - 1] if col_idx - 1 < len(headers) else f"Col{col_idx}" + row_content.append(f"{col_name}: {cell_str}") + row_has_content = True + + if row_has_content: + sheet_text_parts.append(f"ROW {row_idx}: " + " | ".join(row_content)) + + # Also add CSV-like format for compatibility + sheet_text_parts.append("\n--- CSV FORMAT ---") + csv_rows = [] + for row_idx in range(1, sheet.max_row + 1): + csv_row = [] + for col_idx in range(1, sheet.max_column + 1): + cell = sheet.cell(row_idx, col_idx) + cell_value = cell.value + if cell_value is not None: + csv_row.append(str(cell_value)) + else: + csv_row.append("") + csv_rows.append(",".join(f'"{item}"' for item in csv_row)) + + sheet_text_parts.extend(csv_rows) + + # Join all parts for this sheet + sheet_content = '\n'.join(sheet_text_parts) + + # Count URLs in this sheet for logging + sheet_url_count = len(sheet_urls) + total_urls_found += sheet_url_count + + logger.info( + f" Sheet '{sheet_name}' content: {len(sheet_content)} chars, {sheet_url_count} hyperlinks extracted") + + all_text_parts.append(sheet_content) + + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}' with openpyxl: {e}") + continue + + # Join all sheet content + complete_content = '\n\n'.join(all_text_parts) + + logger.info("=" * 80) + logger.info(f"COMPREHENSIVE EXCEL EXTRACTION COMPLETE (OPENPYXL):") + logger.info(f" - Processed {len(sheet_names)} sheets") + logger.info(f" - Total content: {len(complete_content)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_urls)}") + logger.info(f" - Total URLs found: {total_urls_found}") + logger.info("=" * 80) + + # Log extracted URLs + if extracted_urls: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_urls[:10]): # Log first 10 + logger.info(f" URL {i + 1}: {url}") + if len(extracted_urls) > 10: + logger.info(f" ... and {len(extracted_urls) - 10} more URLs") + + # Log sample content + sample_content = complete_content[:2000] if len(complete_content) > 2000 else complete_content + logger.info("SAMPLE OF COMPREHENSIVE EXCEL CONTENT:") + logger.info(sample_content) + if len(complete_content) > 2000: + logger.info(f"... [TRUNCATED - FULL CONTENT IS {len(complete_content)} CHARS] ...") + logger.info("=" * 80) + + return complete_content, extracted_urls + + except Exception as e: + logger.error(f"Error extracting comprehensive Excel content with openpyxl: {e}") + # Fallback to pandas method + return self._extract_full_content_for_urls(content_bytes), [] + + def _extract_full_content_for_urls(self, content_bytes: bytes) -> str: + """Fallback: Extract full Excel content specifically for URL extraction - no limits + + Args: + content_bytes: Excel file content as bytes + + Returns: + Full text content for URL extraction + """ + try: + excel_file = pd.ExcelFile(io.BytesIO(content_bytes)) + sheet_names = excel_file.sheet_names + + all_text_parts = [] + + # Process ALL sheets without limits + for sheet_name in sheet_names: + try: + df = pd.read_excel(excel_file, sheet_name=sheet_name) + if not df.empty: + # Convert entire sheet to string + csv_string = df.to_csv(index=False) + all_text_parts.append(f"\n--- Sheet: '{sheet_name}' ---\n") + all_text_parts.append(csv_string) + except Exception as e: + logger.error(f"Error processing sheet '{sheet_name}': {e}") + continue + + return '\n'.join(all_text_parts) + + except Exception as e: + logger.error(f"Error extracting full Excel content: {e}") + return "" + + def extract_text(self, file_path) -> str: + """Extract text from Excel (file path) - first sheet only with limits + + Args: + file_path: Path to Excel file + + Returns: + Limited text content + """ + excel_file = pd.ExcelFile(file_path) + sheet_names = excel_file.sheet_names + + if not sheet_names: + return "" + + # Only read first sheet + df = pd.read_excel(excel_file, sheet_name=sheet_names[0], nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + + text = f"Excel file with {len(sheet_names)} sheets. Processing first sheet: '{sheet_names[0]}'\n" + text += df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + return text + + def extract_text_from_object(self, file) -> str: + """Extract text from Excel (file object) - first sheet only with limits + + Args: + file: Excel file object + + Returns: + Limited text content + """ + excel_file = pd.ExcelFile(file) + sheet_names = excel_file.sheet_names + + if not sheet_names: + return "" + + # Only read first sheet + df = pd.read_excel(excel_file, sheet_name=sheet_names[0], nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + + text = f"Excel file with {len(sheet_names)} sheets. Processing first sheet: '{sheet_names[0]}'\n" + text += df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + return text \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/markdown_extractor.py b/chatbot/utils/knowledge_service/extractor/markdown_extractor.py new file mode 100644 index 0000000..f83cea0 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/markdown_extractor.py @@ -0,0 +1,247 @@ +"""Markdown extraction functionality - Converts Excel files to Markdown format""" + +import io +import logging +import os +import re +from typing import List, Tuple +import pandas as pd +from tabulate import tabulate + +logger = logging.getLogger('django') + +try: + import openpyxl + HAS_OPENPYXL = True + logger.info("openpyxl available for enhanced Excel processing") +except ImportError: + HAS_OPENPYXL = False + logger.warning("openpyxl not available. Excel hyperlink extraction will be limited.") + + +class MarkdownExtractor: + """Handles Excel to Markdown conversion for better LLM processing""" + + def __init__(self, subdoc_max_chars: int = 500): + self.subdoc_max_chars = subdoc_max_chars + + @staticmethod + def sanitize_cell_content(df): + """ + Cleans up cell content by converting internal line breaks to HTML
      tags. + This step runs BEFORE tabulate. + """ + def replace_breaks(text): + if isinstance(text, str): + # Condense internal whitespace and clean up line breaks for
      conversion + text = re.sub(r'\s+', ' ', text) + text = text.replace('\r\n', '
      ').replace('\n', '
      ').replace('\r', '
      ') + return text.strip() + return text + + return df.map(replace_breaks) + + @staticmethod + def post_process_markdown(markdown_text): + """ + Performs final cleanup on the generated Markdown text by: + 1. Reducing repetitive hyphens within general text. + 2. Standardizing the machine-generated table separator line. + 3. Condensing multiple spaces. + """ + lines = markdown_text.splitlines() + cleaned_lines = [] + + for line in lines: + # 1. Condense multiple hyphens (more than 3 repeating hyphens to 1 hyphen) + line = re.sub(r'-{4,}', '-', line) + + # 2. Check for the machine-generated separator line and standardize it. + if re.search(r'^\s*\|[:\s\-]+\|', line) and not re.search(r'[a-zA-Z0-9<>]', line): + # Count the number of delimiters (pipes) to determine column count. + num_columns = line.count('|') - 1 + if num_columns > 0: + # Create the standard separator: |---|---|...| + standard_separator = '|' + ':---|' * num_columns + cleaned_lines.append(standard_separator) + continue + + # 3. Condense multiple whitespace characters + line = re.sub(r'\s{2,}', ' ', line) + + # Add the cleaned line + cleaned_lines.append(line.strip()) + + # 4. Remove any empty lines created by the filtering process + final_output = '\n'.join([line for line in cleaned_lines if line]) + + # Condense multiple blank lines + final_output = re.sub(r'\n{3,}', '\n\n', final_output) + + return final_output.strip() + + def spreadsheet_to_markdown(self, content_bytes: bytes, filename: str = "spreadsheet") -> str: + """ + Convert Excel/CSV spreadsheet to Markdown format + + Args: + content_bytes: Spreadsheet file content as bytes + filename: Name of the file for header + + Returns: + Markdown formatted text + """ + file_extension = os.path.splitext(filename)[1].lower() + markdown_output = f"# {filename}\n\n" + + try: + if file_extension == '.csv': + # Read CSV without header assumption + df = pd.read_csv(io.BytesIO(content_bytes), header=None, keep_default_na=False) + df.columns = df.columns.astype(str) + df = self.sanitize_cell_content(df) + + markdown_output += f"## {os.path.basename(filename).replace(file_extension, '')}\n\n" + markdown_output += tabulate(df, headers='keys', tablefmt='pipe', showindex=False) + markdown_output += "\n\n---\n" + + elif file_extension in ['.xlsx', '.xls']: + xls = pd.ExcelFile(io.BytesIO(content_bytes)) + for sheet_name in xls.sheet_names: + # Read all content without assuming a header row + df = xls.parse(sheet_name, header=None, keep_default_na=False) + + # Skip empty sheets + if df.empty or len(df) == 0: + logger.warning(f"Sheet '{sheet_name}' is empty, skipping...") + continue + + # Apply cleanup and conversion + df.columns = df.columns.astype(str) + df = self.sanitize_cell_content(df) + + markdown_output += f"## {sheet_name}\n\n" + markdown_output += tabulate(df, headers='keys', tablefmt='pipe', showindex=False) + markdown_output += "\n\n---\n" + + else: + return f"Unsupported file format: {file_extension}" + + return self.post_process_markdown(markdown_output) + + except Exception as e: + logger.error(f"Error converting spreadsheet to markdown: {e}") + return f"Error processing file: {e}" + + def extract_limited_content(self, content_bytes: bytes, max_chars: int = None, filename: str = "spreadsheet") -> str: + """ + Extract limited content from Excel file in Markdown format for LLM processing + + Args: + content_bytes: Excel file content as bytes + max_chars: Maximum characters to extract + filename: Name of the file + + Returns: + Limited Markdown formatted text for LLM processing + """ + if max_chars is None: + max_chars = self.subdoc_max_chars + + try: + # Convert to markdown + markdown_content = self.spreadsheet_to_markdown(content_bytes, filename) + + logger.info("=" * 80) + logger.info("EXCEL TO MARKDOWN CONVERSION COMPLETE") + logger.info(f"Total content: {len(markdown_content)} characters") + logger.info("=" * 80) + + # Apply character limit if needed + if len(markdown_content) > max_chars: + lines = markdown_content.split('\n') + truncated_text = [] + current_length = 0 + + for line in lines: + if current_length + len(line) + 1 > max_chars - 50: + break + truncated_text.append(line) + current_length += len(line) + 1 + + markdown_content = '\n'.join(truncated_text) + "\n\n[Content truncated]" + logger.info(f"Markdown content truncated to {len(markdown_content)} chars") + + # Log sample content + logger.info("=" * 80) + logger.info("MARKDOWN CONTENT BEING SENT TO LLM:") + logger.info("=" * 80) + logger.info(markdown_content[:1000] if len(markdown_content) > 1000 else markdown_content) + if len(markdown_content) > 1000: + logger.info(f"... [TRUNCATED - FULL CONTENT IS {len(markdown_content)} CHARS] ...") + logger.info("=" * 80) + + return markdown_content + + except Exception as e: + logger.error(f"Error extracting Excel content as Markdown: {e}") + return "" + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes, filename: str = "spreadsheet") -> Tuple[str, List[str]]: + """ + Extract COMPLETE Excel content in Markdown format and hyperlinks using openpyxl + + Args: + content_bytes: Excel file content as bytes + filename: Name of the file + + Returns: + Tuple of (comprehensive_markdown_text, extracted_urls) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE EXCEL CONTENT AS MARKDOWN WITH URL EXTRACTION") + logger.info("=" * 80) + + extracted_urls = [] + + # Extract hyperlinks if openpyxl is available + if HAS_OPENPYXL: + try: + wb = openpyxl.load_workbook(io.BytesIO(content_bytes), data_only=True) + for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + for row in sheet.iter_rows(): + for cell in row: + if cell.hyperlink and cell.hyperlink.target: + url = cell.hyperlink.target + if url not in extracted_urls: + extracted_urls.append(url) + logger.info(f"Extracted {len(extracted_urls)} hyperlinks from Excel file") + except Exception as e: + logger.error(f"Error extracting hyperlinks with openpyxl: {e}") + + # Convert to markdown (no character limits for comprehensive extraction) + markdown_content = self.spreadsheet_to_markdown(content_bytes, filename) + + logger.info("=" * 80) + logger.info(f"COMPREHENSIVE MARKDOWN EXTRACTION COMPLETE:") + logger.info(f" - Total content: {len(markdown_content)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_urls)}") + logger.info("=" * 80) + + # Log extracted URLs + if extracted_urls: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_urls[:10]): + logger.info(f" URL {i + 1}: {url}") + if len(extracted_urls) > 10: + logger.info(f" ... and {len(extracted_urls) - 10} more URLs") + + return markdown_content, extracted_urls + + except Exception as e: + logger.error(f"Error extracting comprehensive Excel content as Markdown: {e}") + # Fallback to basic markdown conversion + markdown_content = self.spreadsheet_to_markdown(content_bytes, filename) + return markdown_content, [] \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/pdf_extractor.py b/chatbot/utils/knowledge_service/extractor/pdf_extractor.py new file mode 100644 index 0000000..471c402 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/pdf_extractor.py @@ -0,0 +1,211 @@ +"""PDF extraction functionality""" + +import io +import os +import logging +import tempfile +from typing import List, Dict, Any, Tuple + +import PyPDF2 +from chatbot.utils.knowledge_service.processor.image_processor import HAS_PYMUPDF + +logger = logging.getLogger('django') + +# Additional imports for enhanced features +if HAS_PYMUPDF: + import fitz + +try: + import pdfplumber + + HAS_PDFPLUMBER = True + logger.info("pdfplumber available for enhanced PDF text extraction") +except ImportError: + HAS_PDFPLUMBER = False + logger.warning("pdfplumber not available. Using PyMuPDF/PyPDF2 fallback.") + + +class PDFExtractor: + """Handles PDF content extraction""" + + def __init__(self, image_processor): + self.image_processor = image_processor + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes) -> Tuple[str, List[str]]: + """Extract comprehensive PDF content and hyperlinks using PyMuPDF + + Args: + content_bytes: PDF file content as bytes + + Returns: + Tuple of (comprehensive_text, extracted_hyperlinks) + """ + try: + if not HAS_PYMUPDF: + # Fallback to basic text extraction + text = self.extract_text_enhanced(content_bytes) + return text, [] + + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE PDF CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + text_parts = [] + extracted_hyperlinks = [] + + logger.info(f"Processing {len(doc)} pages for content and hyperlinks...") + + for page_num in range(len(doc)): + page = doc.load_page(page_num) + + # Extract text + page_text = page.get_text() + if page_text.strip(): + text_parts.append(f"[Page {page_num + 1}]\n{page_text}") + + # Extract links/annotations + links = page.get_links() + page_hyperlinks = [] + + for link in links: + if 'uri' in link and link['uri']: + url = link['uri'] + if url.startswith('http') and url not in extracted_hyperlinks: + extracted_hyperlinks.append(url) + page_hyperlinks.append(url) + + if page_hyperlinks: + logger.info(f" Page {page_num + 1}: {len(page_hyperlinks)} hyperlinks found") + for url in page_hyperlinks: + logger.info(f" - {url}") + + doc.close() + + comprehensive_text = '\n'.join(text_parts) + + logger.info(f"PDF extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Hyperlinks extracted: {len(extracted_hyperlinks)}") + + if extracted_hyperlinks: + logger.info("EXTRACTED HYPERLINKS:") + for i, url in enumerate(extracted_hyperlinks[:10]): + logger.info(f" URL {i + 1}: {url}") + if len(extracted_hyperlinks) > 10: + logger.info(f" ... and {len(extracted_hyperlinks) - 10} more URLs") + + return comprehensive_text, extracted_hyperlinks + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error extracting comprehensive PDF content: {e}") + # Fallback to basic text extraction + text = self.extract_text_enhanced(content_bytes) + return text, [] + + def extract_text_enhanced(self, content_bytes: bytes) -> str: + """Enhanced PDF text extraction with multiple methods + + Args: + content_bytes: PDF file content as bytes + + Returns: + Extracted text content + """ + text = "" + + # Try pdfplumber first if available + if HAS_PDFPLUMBER: + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + text_parts = [] + with pdfplumber.open(temp_file_path) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text and page_text.strip(): + text_parts.append(page_text) + text = '\n'.join(text_parts) + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + if text and len(text.strip()) > 50: + logger.info(f"pdfplumber extracted {len(text)} characters") + return text + except Exception as e: + logger.error(f"pdfplumber failed: {e}") + + # Try PyMuPDF next + if HAS_PYMUPDF and not text: + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + text_parts = [] + for page_num in range(len(doc)): + page = doc.load_page(page_num) + text_parts.append(page.get_text()) + text = '\n'.join(text_parts) + doc.close() + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + if text and len(text.strip()) > 50: + logger.info(f"PyMuPDF extracted {len(text)} characters") + return text + except Exception as e: + logger.error(f"PyMuPDF failed: {e}") + + # Fallback to PyPDF2 + if not text: + try: + pdf_reader = PyPDF2.PdfReader(io.BytesIO(content_bytes)) + text_parts = [] + for page in pdf_reader.pages: + text_parts.append(page.extract_text()) + text = '\n'.join(text_parts) + logger.info(f"PyPDF2 extracted {len(text)} characters") + except Exception as e: + logger.error(f"PyPDF2 failed: {e}") + + # Try OCR if text extraction failed + if (not text or len(text.strip()) < 50) and self.image_processor.enable_ocr: + logger.info("Attempting OCR on potentially scanned document") + ocr_text = self.image_processor.perform_ocr_on_pdf(content_bytes) + if ocr_text: + text = text + "\n\n[OCR Content]\n" + ocr_text if text else ocr_text + + return text + + def extract_text(self, file) -> str: + """Extract text from PDF (fallback method) + + Args: + file: PDF file object + + Returns: + Extracted text content + """ + pdf_reader = PyPDF2.PdfReader(file) + text_parts = [] + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + text_parts.append(page.extract_text()) + return '\n'.join(text_parts) \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/text_extractor.py b/chatbot/utils/knowledge_service/extractor/text_extractor.py new file mode 100644 index 0000000..ff1a8a2 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/text_extractor.py @@ -0,0 +1,103 @@ +"""Simple text extraction functionality for CSV and TXT files""" + +import io +import logging +from typing import List, Tuple +import pandas as pd + +logger = logging.getLogger('django') + + +class CSVExtractor: + """Handles CSV content extraction""" + + def __init__(self, excel_max_rows: int = 50, excel_max_cols: int = 20): + self.excel_max_rows = excel_max_rows + self.excel_max_cols = excel_max_cols + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes) -> Tuple[str, List[str]]: + """ + Extract comprehensive CSV content (CSV files don't have hyperlinks, but we maintain consistency) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE CSV CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + # For CSV, there are no embedded hyperlinks, so just extract all text + df_full = pd.read_csv(io.BytesIO(content_bytes)) + comprehensive_text = df_full.to_csv(index=False) + + logger.info(f"CSV extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(f" - Rows: {len(df_full)}, Columns: {len(df_full.columns)}") + logger.info(" - No hyperlinks (CSV format doesn't support embedded links)") + + return comprehensive_text, [] + + except Exception as e: + logger.error(f"Error extracting comprehensive CSV content: {e}") + return "", [] + + def extract_text(self, file_path) -> str: + """ + Extract text from CSV (file path) with limits + """ + df = pd.read_csv(file_path, nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + return df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + + def extract_text_from_object(self, file) -> str: + """ + Extract text from CSV (file object) with limits + """ + df = pd.read_csv(file, nrows=self.excel_max_rows) + if len(df.columns) > self.excel_max_cols: + df = df.iloc[:, :self.excel_max_cols] + return df.to_string(max_rows=self.excel_max_rows, max_cols=self.excel_max_cols) + + +class TXTExtractor: + """Handles TXT content extraction""" + + def extract_comprehensive_content_for_urls(self, content_bytes: bytes) -> Tuple[str, List[str]]: + """ + Extract comprehensive TXT content (TXT files don't have hyperlinks, but we maintain consistency) + """ + try: + logger.info("=" * 80) + logger.info("EXTRACTING COMPREHENSIVE TXT CONTENT FOR URL EXTRACTION") + logger.info("=" * 80) + + # For TXT, there are no embedded hyperlinks, so just extract all text + try: + comprehensive_text = content_bytes.decode('utf-8', errors='ignore') + except: + comprehensive_text = str(content_bytes, errors='ignore') + + logger.info(f"TXT extraction complete:") + logger.info(f" - Text content: {len(comprehensive_text)} characters") + logger.info(" - No hyperlinks (TXT format doesn't support embedded links)") + + return comprehensive_text, [] + + except Exception as e: + logger.error(f"Error extracting comprehensive TXT content: {e}") + return "", [] + + def extract_text(self, file_path) -> str: + """ + Extract text from plain text file (file path) + """ + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + + def extract_text_from_object(self, file) -> str: + """ + Extract text from plain text file (file object) + """ + content = file.read() + if isinstance(content, bytes): + content = content.decode('utf-8', errors='ignore') + return content \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/extractor/url_extractor.py b/chatbot/utils/knowledge_service/extractor/url_extractor.py new file mode 100644 index 0000000..690d669 --- /dev/null +++ b/chatbot/utils/knowledge_service/extractor/url_extractor.py @@ -0,0 +1,134 @@ +"""URL extraction and processing functionality""" + +import re +import logging +from typing import List, Set +from urllib.parse import urlparse +from chatbot.utils.knowledge_service.base.extraction_config import EXCLUDED_DOMAINS, GOOGLE_DOC_PATTERNS +from chatbot.models import FileTypeChoices + +logger = logging.getLogger('django') + + +class URLExtractor: + """Handles URL extraction and validation for documents""" + + def __init__(self): + self.processed_urls: Set[str] = set() + + def extract_urls_from_text(self, text: str) -> List[str]: + """ + Extract all URLs from text content with improved regex + """ + try: + # Log the full text for debugging + logger.info("=" * 80) + logger.info("EXTRACTING URLs FROM TEXT") + logger.info("=" * 80) + + # First, let's look specifically for patterns like "word: URL" on separate lines + lines = text.split('\n') + manual_urls = [] + + for i, line in enumerate(lines): + line = line.strip() + + # Check if line contains http anywhere + if 'http' in line: + # Extract all URLs from this line + url_pattern = r'https?://[^\s\n\r]+' + found_urls = re.findall(url_pattern, line, re.IGNORECASE) + manual_urls.extend(found_urls) + + # Also check if previous line ends with description and this line is a URL + if i > 0 and line.startswith('http'): + if line not in manual_urls: + manual_urls.append(line) + + # Enhanced URL patterns for more thorough extraction + url_patterns = [ + # Catch ALL URLs starting with http/https + r'https?://[^\s\n\r]+', + # Google specific patterns + r'https://docs\.google\.com/[^/\s]+/d/[A-Za-z0-9_-]+[^\s\n\r]*', + r'https://drive\.google\.com/[^/\s]+/d/[A-Za-z0-9_-]+[^\s\n\r]*', + ] + + urls = [] + + # Add manually found URLs first + urls.extend(manual_urls) + + # Then use regex patterns on the full text + for pattern in url_patterns: + found_urls = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE) + urls.extend(found_urls) + + # Clean and process URLs + processed_urls = [] + for url in urls: + url = url.strip() + # Remove trailing punctuation and special chars + url = re.sub(r'[.,;:!?)\]}>]+$', '', url) + if url.startswith('www.'): + url = 'https://' + url + processed_urls.append(url) + + # Remove duplicates while preserving order + unique_urls = [] + seen = set() + + for url in processed_urls: + # Normalize by removing trailing slashes + normalized = url.rstrip('/') + + # For Google Docs/Sheets, normalize the gid parameter + if 'docs.google.com/spreadsheets' in normalized and '#gid=' in normalized: + base_url = normalized.split('#gid=')[0] + gid_part = '#gid=' + normalized.split('#gid=')[1].split('&')[0].split('/')[0] + normalized = base_url + gid_part + + if normalized not in seen and len(normalized) > 10: + unique_urls.append(url) + seen.add(normalized) + + logger.info("=" * 80) + logger.info(f"EXTRACTED {len(unique_urls)} UNIQUE URLs:") + logger.info("=" * 80) + for i, url in enumerate(unique_urls): + logger.info(f"URL {i + 1}: {url}") + logger.info("=" * 80) + + return unique_urls + + except Exception as e: + logger.error(f"Error extracting URLs: {e}") + return [] + + def is_document_url(self, url: str, depth: int = 0) -> bool: + """ + Check if URL points to a document - validates against supported formats + """ + try: + # Check domain exclusions + for domain in EXCLUDED_DOMAINS: + if domain in url.lower(): + return False + + # Special handling for Google Docs + if any(pattern in url for pattern in GOOGLE_DOC_PATTERNS): + return True + + parsed_url = urlparse(url) + path = parsed_url.path.lower() + + if '.' in path: + extension = path.rsplit('.', 1)[-1] + # Use FileTypeChoices to validate + return FileTypeChoices.is_valid_extension(extension) + + return False + + except Exception as e: + logger.error(f"Error checking if URL is document: {e}") + return False \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/media_utils.py b/chatbot/utils/knowledge_service/media_utils.py new file mode 100644 index 0000000..960b8e1 --- /dev/null +++ b/chatbot/utils/knowledge_service/media_utils.py @@ -0,0 +1,98 @@ +from chatbot.models import FileTypeChoices + + +def get_media_type_from_ai_data(document_type): + """Map AI-detected document type to our media type choices""" + if isinstance(document_type, dict): + doc_type_text = document_type.get('type', '') + else: + doc_type_text = document_type or '' + + if not doc_type_text: + return FileTypeChoices.TXT.value + + if doc_type_text and doc_type_text != '': + doc_type_text = doc_type_text.lower() + type_mapping = { + 'report': FileTypeChoices.PDF, + 'spreadsheet': FileTypeChoices.XLSX, + 'document': FileTypeChoices.DOCX, + 'text': FileTypeChoices.TXT, + 'csv': FileTypeChoices.CSV, + 'excel': FileTypeChoices.XLSX, + 'word': FileTypeChoices.DOCX, + 'pdf': FileTypeChoices.PDF + } + + for key, value in type_mapping.items(): + if key in doc_type_text: + return value.value + return FileTypeChoices.TXT.value + + +def build_key_values(data_dict): + """Build key-value pairs from document data with metadata tracking""" + key_values = [] + array_fields_metadata = [] # Track which fields were originally arrays + + if data_dict.get('title'): + key_values.append({'key': 'TITLE', 'value': str(data_dict['title']), 'source': 'ai'}) + + organization_value = data_dict.get('organization', '') + key_values.append({'key': 'ORGANIZATION', 'value': str(organization_value), 'source': 'ai'}) + + # ADD GEOGRAPHY HANDLING + geography_value = data_dict.get('geography', '') + if geography_value: + key_values.append({'key': 'GEOGRAPHY', 'value': str(geography_value), 'source': 'ai'}) + + document_type = data_dict.get('document_type') + if document_type: + if isinstance(document_type, dict): + doc_type_value = document_type.get('type', '') + if doc_type_value: + doc_type_value = doc_type_value.title() + key_values.append({'key': 'DOCUMENT_TYPE', 'value': str(doc_type_value), 'source': 'ai'}) + else: + doc_type_value = document_type.title() if document_type else '' + key_values.append({'key': 'DOCUMENT_TYPE', 'value': str(doc_type_value), 'source': 'ai'}) + + if data_dict.get('key_entities') and len(data_dict['key_entities']) > 0: + key_values.append({'key': 'KEY ENTITIES', 'value': ', '.join(map(str, data_dict['key_entities'])), 'source': 'ai'}) + + # ENHANCED: Handle structured content with proper array formatting + if data_dict.get('structured_content') and isinstance(data_dict['structured_content'], dict): + for heading, content in data_dict['structured_content'].items(): + if heading.upper() in [ + 'BASIC INFORMATION', 'GENERAL INFORMATION', 'TAGS', 'KEYWORDS', + 'CATEGORIES', 'CLASSIFICATION', 'TAGS FOR CLASSIFICATION' + ]: + continue + + key_name = heading.upper() + + # Format arrays as multi-line strings with bullet points + if isinstance(content, list): + # Track that this field was originally an array + array_fields_metadata.append(key_name) + + # Ensure all list items are strings + string_items = [str(item) for item in content if item is not None] + formatted_content = '\n'.join([f"• {item}" for item in string_items]) + key_values.append({ + 'key': key_name, + 'value': formatted_content, + 'original_type': 'array', + 'source': 'ai' # Mark as AI-extracted + }) + else: + # Handle text that might already be formatted + content_str = str(content) if content is not None else '' + key_values.append({ + 'key': key_name, + 'value': content_str, + 'original_type': 'string', + 'source': 'ai' # Mark as AI-extracted + }) + + return key_values, array_fields_metadata \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/openai_vector_store/vector_store_utils.py b/chatbot/utils/knowledge_service/openai_vector_store/vector_store_utils.py new file mode 100644 index 0000000..72453ba --- /dev/null +++ b/chatbot/utils/knowledge_service/openai_vector_store/vector_store_utils.py @@ -0,0 +1,133 @@ +import logging +import os +import requests + +api_key = os.getenv("OPENAI_API_KEY") +logger = logging.getLogger("django") + +OPENAI_HEADERS = { + "Authorization": f"Bearer {api_key}", + "OpenAI-Beta": "assistants=v2", +} + +def get_vector_store_id(media): + import json_repair + tool = media.company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + vector_store_id = None + if tool and isinstance(tool, dict): + tool_list = tool.get("tool") + if isinstance(tool_list, list) and tool_list: + first_tool = tool_list[0] + if isinstance(first_tool, dict): + vs_ids = first_tool.get("vector_store_ids") + if isinstance(vs_ids, list) and vs_ids: + vector_store_id = vs_ids[0] + if not vector_store_id: + logger.error( + f"Vector store ID not found in tool_context during delete. {media.id}" + ) + return None + return vector_store_id + + +def upload_file_to_openai(file_name, file_content): + """Upload a file to OpenAI Files API""" + try: + response = requests.post( + "https://api.openai.com/v1/files", + headers=OPENAI_HEADERS, + files={ + "file": (file_name, file_content), + }, + data={ + "purpose": "assistants", + }, + timeout=30, + ) + + response.raise_for_status() + data = response.json() + logger.info(f"OpenAI file upload response: {data}") + return 200, data + + except Exception as e: + logger.exception("Error while uploading file to OpenAI") + return 500, None + + +def add_file_to_vector_store(media, metadata=None): + """Add uploaded file to vector store with metadata""" + + attributes = {} + if isinstance(metadata, dict): + attributes = { + str(k): str(v) + for k, v in metadata.items() + if v is not None + } + + try: + vector_store_id = get_vector_store_id(media) + print("vector store id: ", vector_store_id) + file_id = getattr(media, "external_file_id", None) + print("file_id: ", file_id) + if not file_id or not vector_store_id: + return 500, None + url = f"https://api.openai.com/v1/vector_stores/{vector_store_id}/files" + + payload = { + "file_id": file_id, + "attributes": attributes, + } + + response = requests.post( + url, + headers={**OPENAI_HEADERS, "Content-Type": "application/json"}, + json=payload, + timeout=60, + ) + + response.raise_for_status() + data = response.json() + logger.info(f"Vector store attach response: {data}") + return 200, data + + except Exception as e: + logger.exception( + f"Failed to add file to vector store. File ID: {file_id}" + ) + return 500, None + + +def delete_file_from_vector_store(media): + """ + Remove a file from an OpenAI Vector Store + """ + + try: + vector_store_id = get_vector_store_id(media) + print("vector store id: ", vector_store_id) + file_id = getattr(media, "external_file_id", None) + print("file_id: ", file_id) + if not file_id or not vector_store_id: + return 500, None + url = f"https://api.openai.com/v1/vector_stores/{vector_store_id}/files/{file_id}" + + response = requests.delete( + url, + headers=OPENAI_HEADERS, + timeout=60, + ) + + response.raise_for_status() + data = response.json() + + logger.info(f"Deleted file from vector store. file_id={file_id}, response={data}") + return 200, data + + except Exception as e: + logger.exception(f"Failed to delete file from vector store. file_id={file_id}") + return 500, None diff --git a/chatbot/utils/knowledge_service/processor/ai_processor.py b/chatbot/utils/knowledge_service/processor/ai_processor.py new file mode 100644 index 0000000..71cc90f --- /dev/null +++ b/chatbot/utils/knowledge_service/processor/ai_processor.py @@ -0,0 +1,271 @@ +import json +import logging +from typing import Dict, Any, List +from jinja2 import Template +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model +import os +from chatbot.utils.knowledge_service.base.extraction_utils import find_explicit_tag_sections + +logger = logging.getLogger('django') + + +class AIContentProcessor: + """Handles content extraction using AWS Bedrock LLM""" + + def __init__(self, main_doc_max_chars: int = 3000): + self.main_doc_max_chars = main_doc_max_chars + + def create_default_response(self, document_text: str = "", + extracted_images: List[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Create default response structure + """ + return { + "title": "", + "organization": "", + "tags": [], + "exact_content": document_text, # Always preserve content + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": extracted_images or [] + } + + def validate_and_enhance_result(self, result: Dict[str, Any], + document_text: str) -> Dict[str, Any]: + """ + Validate and enhance the extracted result + """ + # Ensure all required fields exist + default_response = { + "title": "", + "organization": "", + "tags": [], + "exact_content": "", + "summary": "", + "document_type": "", + "key_entities": [], + "url": [], + "subdocument": [], + "images": [] + } + + for key in default_response: + if key not in result: + result[key] = default_response[key] + + # Ensure exact_content is preserved + if not result.get('exact_content'): + result['exact_content'] = document_text + + # Find explicit tag sections + explicit_tag_sections = find_explicit_tag_sections(document_text) + + # Validate tags + if result.get('tags'): + validated_tags = [] + for tag in result['tags']: + if isinstance(tag, str): + # Try to parse as JSON first + try: + parsed_tag = json_repair.repair_json(tag, return_objects=True) + if isinstance(parsed_tag, dict) and 'text' in parsed_tag: + # Successfully parsed as tag dict + validated_tags.append(parsed_tag) + else: + # Not a valid tag dict, treat as plain string + validated_tags.append({"text": tag, "source": "generated"}) + except: + # Failed to parse as JSON, treat as plain string + validated_tags.append({"text": tag, "source": "generated"}) + elif isinstance(tag, dict) and 'text' in tag: + # Already a proper dict with text field + validated_tags.append(tag) + result['tags'] = validated_tags + + # Ensure minimum content quality + if not result.get('title') or len(result['title'].strip()) < 3: + content_words = document_text.split()[:10] + result['title'] = ' '.join(content_words).strip() + '...' if content_words else 'Untitled Document' + + return result + + def extract_basic_content(self, document_text: str, company_bot, + extracted_images: List[Dict[str, Any]] = None, + other_data: dict = None, is_subdoc: bool = False) -> Dict[str, Any]: + """ + Extract basic content using Bedrock + """ + default_response = self.create_default_response(document_text, extracted_images) + + try: + logger.info("Processing content with Bedrock...") + logger.info(f"Passing Text to llm: {document_text}") + + # Preserve complete content + complete_content = document_text + + system_prompt = [ + { + 'text': company_bot.context + }, + ] + + tool_context_data = json_repair.repair_json( + company_bot.tool_context, return_objects=True + ) if isinstance(company_bot.tool_context, str) else company_bot.tool_context + + if isinstance(tool_context_data, list) and len(tool_context_data) > 0: + tool_context_data = tool_context_data[0] + + end_context = company_bot.end_context + + if not end_context: + print("Early return due to no data in end context value.") + logger.error("Early return due to no data in end context value.") + default_response['exact_content'] = complete_content + return default_response + + master_document_types = None + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + other_params = json_repair.repair_json( + company_bot.other_params, return_objects=True + ) if isinstance(company_bot.other_params, str) else company_bot.other_params + master_document_types = other_params.get('master_document_types', []) + except Exception as e: + print(f"Error parsing master_document_types: {e}") + logger.error(f"Error parsing master_document_types: {e}") + default_response['exact_content'] = complete_content + return default_response + + # Create analysis version if text is too long + analysis_text = document_text + max_analysis_chars = self.main_doc_max_chars + + if len(document_text) > max_analysis_chars: + first_part = document_text[:max_analysis_chars // 2] + last_part = document_text[-(max_analysis_chars // 2):] + analysis_text = first_part + f"\n\n[SAMPLE - Full: {len(document_text)} chars]\n\n" + last_part + + # Include image information in context if available + image_context = "" + if extracted_images: + image_context = f"\n\nDocument contains {len(extracted_images)} embedded images." + + context_data = { + "document_text": analysis_text, + "extracted_images": extracted_images, + "master_tags": other_data.get('master_tag', None) if other_data else None, + "master_document_types": master_document_types + } + + template = Template(end_context) + end_context = template.render(context_data) + logger.info(f"Updated Tag Context: \n {end_context}") + + messages = [{ + 'role': 'user', + 'content': [{'text': f"{end_context}"}] + }] + + print("Bedrock: Extraction call started.") + response = handle_bedrock_model( + system_prompt=system_prompt, + messages=messages, + model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + company_bot=company_bot, + tools=tool_context_data, + aws_key=os.getenv('SG_REPO_AWS_ACCESS_KEY_ID'), + aws_secret_key=os.getenv('SG_REPO_AWS_SECRET_ACCESS_KEY') + ) + logger.info(f"Bedrock response type: {type(response)}") + logger.info("Bedrock response:\n%s", json.dumps(response, indent=2)) + print(f"Bedrock response type: {type(response)}") + print("--------\n\n") + + # Enhanced response type validation + if not isinstance(response, dict): + error_msg = ("AI processing failed - unable to extract structured data from document. " + "Please try uploading the file again.") + logger.error( + f"LLM returned unexpected response type: {type(response)}. Expected dictionary. " + f"Response preview: {str(response)[:200] if response else 'None'}") + + if not is_subdoc: + # For main document, this is a critical error - stop processing + raise ValueError(error_msg) + else: + # For subdocument, handle gracefully + default_response['exact_content'] = complete_content + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response + + # Extract the actual data from response + extracted_data = response.pop("parameters", response.pop("input", response)) + if not extracted_data or not isinstance(extracted_data, dict): + error_msg = "AI processing failed - response structure is invalid. Please try uploading the file again." + logger.error( + f"LLM response missing expected data structure. Response keys: {list(response.keys()) if response else 'None'}") + + if not is_subdoc: + raise ValueError(error_msg) + + default_response['exact_content'] = complete_content + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response + + # Preserve complete content + extracted_data['exact_content'] = complete_content + + # Add images if available + if extracted_images: + extracted_data['images'] = extracted_images + + # Validate and enhance result + result = self.validate_and_enhance_result(extracted_data, complete_content) + + if not result.get('title') or not result['title'].strip(): + error_msg = f"AI failed to extract title for {'subdocument' if is_subdoc else 'main document'}" + logger.error(error_msg) + if is_subdoc: + result['extraction_error'] = error_msg + result['error'] = error_msg + result['error_type'] = 'title_extraction_failed' + result['title_extraction_failed'] = True + else: + # For main document, raise exception to stop processing + raise ValueError(error_msg) + + logger.info("Bedrock extraction successful") + return result + + except ValueError as ve: + # Re-raise ValueError for main document processing failures + logger.error(f"LLM processing validation error: {str(ve)}") + raise + except Exception as e: + error_msg = f"LLM processing failed with unexpected error: {str(e)}" + logger.error(error_msg) + default_response['exact_content'] = document_text + if not is_subdoc: + # For main document, raise the exception to stop processing + raise ValueError(error_msg) + else: + # For subdocument, handle gracefully + default_response['extraction_error'] = error_msg + default_response['error'] = error_msg + default_response['error_type'] = 'ai_processing_failed' + default_response['title_extraction_failed'] = True + return default_response \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/processor/image_processor.py b/chatbot/utils/knowledge_service/processor/image_processor.py new file mode 100644 index 0000000..2dda6e3 --- /dev/null +++ b/chatbot/utils/knowledge_service/processor/image_processor.py @@ -0,0 +1,294 @@ +import io +import os +import base64 +import logging +import tempfile +from typing import List, Dict, Any + +logger = logging.getLogger('django') + +# Check for optional image processing libraries +try: + import fitz # PyMuPDF for better PDF handling + + HAS_PYMUPDF = True + logger.info("PyMuPDF available for enhanced PDF processing") +except ImportError: + HAS_PYMUPDF = False + logger.warning("PyMuPDF not available. Using PyPDF2 for text extraction only.") + +try: + from PIL import Image + + HAS_PIL = True + logger.info("PIL available for image processing") +except ImportError: + HAS_PIL = False + logger.warning("PIL not available. Image processing will be limited.") + +try: + import pytesseract + from PIL import Image as PILImage + + HAS_OCR = True + logger.info("pytesseract available for OCR processing") +except ImportError: + HAS_OCR = False + logger.warning("pytesseract not available. Scanned document processing will be limited.") + + +class ImageProcessor: + """Handles image extraction and processing from documents""" + + def __init__(self, enable_ocr: bool = True, compress_images: bool = True, + extract_images: bool = False): + """Initialize image processor + + Args: + enable_ocr: Enable OCR processing + compress_images: Compress images before encoding + extract_images: Enable image extraction from documents + """ + self.enable_ocr = enable_ocr and HAS_OCR + self.compress_images = compress_images + self.extract_images = extract_images + + def image_to_base64(self, image_bytes: bytes, image_format: str = "PNG") -> str: + """Convert image bytes to base64 string + + Args: + image_bytes: Raw image bytes + image_format: Image format (PNG, JPEG, etc.) + + Returns: + Base64 encoded image string + """ + try: + if HAS_PIL and self.compress_images: + # Use PIL to potentially optimize/convert image + image = Image.open(io.BytesIO(image_bytes)) + buffer = io.BytesIO() + + # Convert to RGB if necessary + if image.mode in ('RGBA', 'LA'): + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = background + + # Resize if too large + max_dimension = 1024 + if max(image.size) > max_dimension: + image.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS) + + image.save(buffer, format="JPEG", quality=85, optimize=True) + image_bytes = buffer.getvalue() + + # Encode to base64 + base64_string = base64.b64encode(image_bytes).decode('utf-8') + mime_type = f"image/{image_format.lower()}" + return f"data:{mime_type};base64,{base64_string}" + + except Exception as e: + logger.error(f"Error converting image to base64: {e}") + return "" + + def extract_images_from_pdf_pymupdf(self, content_bytes: bytes) -> List[Dict[str, Any]]: + """Extract images from PDF using PyMuPDF + + Args: + content_bytes: PDF file content as bytes + + Returns: + List of extracted images with metadata + """ + images = [] + + # Check if image extraction is enabled + if not self.extract_images: + return images + + if not HAS_PYMUPDF: + return images + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + + for page_num in range(len(doc)): + page = doc.load_page(page_num) + image_list = page.get_images() + + max_images_per_page = 10 + + for img_index, img in enumerate(image_list[:max_images_per_page]): + try: + xref = img[0] + pix = fitz.Pixmap(doc, xref) + + # Skip very small images + if pix.width < 100 or pix.height < 100: + pix = None + continue + + # Skip very large images + if pix.width * pix.height > 2048 * 2048: + pix = None + continue + + # Convert to PNG bytes + if pix.n - pix.alpha < 4: # GRAY or RGB + img_bytes = pix.tobytes("png") + base64_image = self.image_to_base64(img_bytes, "PNG") + + if base64_image: + images.append({ + "page": page_num + 1, + "index": img_index, + "width": pix.width, + "height": pix.height, + "base64": base64_image, + "format": "png" + }) + + pix = None + + except Exception as e: + logger.error(f"Error extracting image {img_index} from page {page_num + 1}: {e}") + + if len(images) > 50: + logger.warning("Reached maximum image limit (50)") + break + + doc.close() + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + logger.info(f"Extracted {len(images)} images from PDF") + return images + + except Exception as e: + logger.error(f"Error extracting images from PDF: {e}") + return [] + + def extract_images_from_docx(self, content_bytes: bytes) -> List[Dict[str, Any]]: + """Extract images from DOCX file + + Args: + content_bytes: DOCX file content as bytes + + Returns: + List of extracted images with metadata + """ + images = [] + + # Check if image extraction is enabled + if not self.extract_images: + return images + + try: + import docx + + with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = docx.Document(temp_file_path) + + image_count = 0 + for rel in doc.part.rels.values(): + if "image" in rel.target_ref: + try: + image_part = rel.target_part + image_bytes = image_part.blob + + if len(image_bytes) < 1000: + continue + + content_type = image_part.content_type + image_format = "PNG" + if "jpeg" in content_type or "jpg" in content_type: + image_format = "JPEG" + + base64_image = self.image_to_base64(image_bytes, image_format) + + if base64_image: + images.append({ + "index": image_count, + "base64": base64_image, + "format": image_format.lower() + }) + image_count += 1 + + except Exception as e: + logger.error(f"Error extracting image from DOCX: {e}") + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + logger.info(f"Extracted {len(images)} images from DOCX") + return images + + except Exception as e: + logger.error(f"Error extracting images from DOCX: {e}") + return [] + + def perform_ocr_on_pdf(self, content_bytes: bytes) -> str: + """Perform OCR on scanned PDF pages + + Args: + content_bytes: PDF file content as bytes + + Returns: + Extracted text from OCR + """ + if not self.enable_ocr or not HAS_PYMUPDF: + return "" + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file: + temp_file.write(content_bytes) + temp_file_path = temp_file.name + + try: + doc = fitz.open(temp_file_path) + ocr_text_parts = [] + + for page_num in range(min(10, len(doc))): + page = doc.load_page(page_num) + + # Check if page has extractable text + page_text = page.get_text() + if len(page_text.strip()) > 50: + continue + + # Convert page to image for OCR + pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) + img_data = pix.tobytes("png") + + # Perform OCR + image = PILImage.open(io.BytesIO(img_data)) + ocr_text = pytesseract.image_to_string(image, lang='eng') + + if ocr_text.strip(): + ocr_text_parts.append(f"[Page {page_num + 1} - OCR]\n{ocr_text}") + + pix = None + + doc.close() + return '\n\n'.join(ocr_text_parts) + + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + except Exception as e: + logger.error(f"Error performing OCR: {e}") + return "" \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/processor/url_processor.py b/chatbot/utils/knowledge_service/processor/url_processor.py new file mode 100644 index 0000000..d1c2e6b --- /dev/null +++ b/chatbot/utils/knowledge_service/processor/url_processor.py @@ -0,0 +1,252 @@ +import logging +import requests +import time +from typing import Dict, Any, Tuple, Optional +from urllib.parse import urlparse +from chatbot.models import FileTypeChoices +from chatbot.utils.knowledge_service.base.extraction_config import DEFAULT_HEADERS, ACCESS_DENIED_PATTERNS +from chatbot.utils.knowledge_service.base.extraction_utils import convert_google_drive_url + +logger = logging.getLogger('django') + + +class DocumentURLProcessor: + """Handles document downloading and processing from URLs""" + + def __init__(self, url_cache: dict = None, max_file_size_mb: int = 50): + self.url_cache = url_cache or {} + self.max_file_size_mb = max_file_size_mb + self.max_file_size_bytes = self.max_file_size_mb * 1024 * 1024 + + def check_google_access_denial(self, response, download_url: str) -> Optional[Dict[str, Any]]: + """ + Check if Google Drive/Docs response indicates access denial + """ + content_type = response.headers.get('content-type', '').lower() + + # Enhanced Google Drive permission detection + if 'drive.google.com' in download_url or 'docs.google.com' in download_url: + # Check if response is HTML-like + if 'html' in content_type or response.text.strip().startswith( + ' Optional[Dict[str, Any]]: + """ + Validate file format based on URL extension + """ + if url_extension and not FileTypeChoices.is_valid_extension(url_extension): + error_info = { + 'error': f'Unsupported file format: .{url_extension}', + 'error_type': 'unsupported_format', + 'url': url + } + logger.error(f"Unsupported file format .{url_extension} for URL {url}") + return error_info + return None + + def validate_content_type(self, content_type: str, url: str) -> Optional[Dict[str, Any]]: + """ + Validate content type for document processing + """ + # Check if it's HTML content that shouldn't be processed + if 'html' in content_type and not any( + indicator in content_type for indicator in ['pdf', 'spreadsheet', 'excel', 'word', 'csv'] + ): + # This is HTML content, likely an error or sign-in page + logger.warning(f"Received HTML response for {url}, not processing as document") + error_info = { + 'error': 'This link returned a web page instead of a document file. This can happen with ' + 'restricted access or unsupported formats', + 'error_type': 'invalid_content_type', + 'url': url + } + return error_info + return None + + def download_document(self, url: str, is_subdoc: bool = False) -> Tuple[ + Optional[bytes], Optional[Dict[str, Any]], str]: + """ + Download document from URL with error handling + """ + try: + logger.info(f"Downloading from: {url} (subdoc: {is_subdoc})") + + # Check cache + if url in self.url_cache: + cached_result = self.url_cache[url] + if isinstance(cached_result, dict) and 'error' in cached_result: + return None, cached_result, "" + if isinstance(cached_result, str): + # Return cached text as bytes + return cached_result.encode('utf-8'), None, "" + return cached_result, None, "" + + # Convert Google Drive URLs to downloadable format + download_url = convert_google_drive_url(url) + if download_url is None: + logger.info(f"Skipped non-document URL: {url}") + return None, None, "" + if download_url != url: + logger.info(f"Converted to: {download_url}") + + response = None + max_retries = 2 + retry_count = 0 + + while retry_count < max_retries: + try: + response = requests.get(download_url, headers=DEFAULT_HEADERS, + timeout=60, allow_redirects=True) + response.raise_for_status() + + if response and response.content: + content_size = len(response.content) + if content_size > self.max_file_size_bytes: + content_size_mb = content_size / (1024 * 1024) + error_info = { + 'error': f'File size ({content_size_mb:.2f} MB) exceeds the maximum allowed ' + f'size of {self.max_file_size_mb} MB. Please reduce the file size.', + 'error_type': 'file_size_exceeded', + 'url': url + } + logger.error(f"File too large from URL {url}: {content_size_mb:.2f} MB") + self.url_cache[url] = error_info + return None, error_info, "" + break + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 500 and retry_count < max_retries - 1: + logger.warning(f"500 error, retrying... (attempt {retry_count + 1})") + retry_count += 1 + time.sleep(2) # Wait before retry + + # Try alternative URL format for Google Drive + if 'drive.google.com' in download_url and '/d/' in url: + file_id = url.split('/d/')[1].split('/')[0] + # Try alternative format + download_url = f"https://drive.google.com/uc?export=download&id={file_id}" + logger.info(f"Trying alternative URL format: {download_url}") + else: + raise + + except requests.exceptions.Timeout: + if retry_count < max_retries - 1: + logger.warning(f"Timeout, retrying... (attempt {retry_count + 1})") + retry_count += 1 + time.sleep(2) + else: + raise + + if not response: + raise Exception("Failed to get response after retries") + + # Get content type (THIS IS THE KEY LINE) + content_type = response.headers.get('content-type', '').lower() + logger.info(f"Response content_type: {content_type}") + + # Check for Google access denial + access_error = self.check_google_access_denial(response, download_url) + if access_error: + self.url_cache[url] = access_error + return None, access_error, content_type + + # Validate file format based on URL extension + parsed_url = urlparse(url) + path = parsed_url.path.lower() + url_extension = None + + # Extract extension from URL path + if '.' in path: + url_extension = path.rsplit('.', 1)[-1] + format_error = self.validate_file_format(url, url_extension) + if format_error: + self.url_cache[url] = format_error + return None, format_error, content_type + + # Validate content type + content_error = self.validate_content_type(content_type, url) + if content_error: + self.url_cache[url] = content_error + return None, content_error, content_type + + return response.content, None, content_type + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 403: + error_info = { + 'error': f'Permission denied accessing {url}', + 'error_type': 'permission_denied', + 'status_code': 403, + 'url': url + } + elif e.response.status_code == 404: + error_info = { + 'error': f'Document not found at {url}', + 'error_type': 'not_found', + 'status_code': 404, + 'url': url + } + else: + error_info = { + 'error': f'HTTP error {e.response.status_code} accessing {url}', + 'error_type': 'http_error', + 'status_code': e.response.status_code, + 'url': url + } + logger.error(f"HTTP error extracting from URL {url}: {e}") + self.url_cache[url] = error_info + return None, error_info, "" + + except requests.exceptions.Timeout: + error_info = { + 'error': f'Timeout accessing {url}', + 'error_type': 'timeout', + 'url': url + } + logger.error(f"Timeout extracting from URL {url}") + self.url_cache[url] = error_info + return None, error_info, "" + + except Exception as e: + error_info = { + 'error': f'Failed to extract from {url}: {str(e)}', + 'error_type': 'extraction_error', + 'url': url + } + logger.error(f"Failed to extract from URL {url}: {e}") + self.url_cache[url] = error_info + return None, error_info, "" + + def determine_file_type(self, content_bytes: bytes, content_type: str, + url: str) -> Tuple[bool, bool, bool, bool, bool]: + """ + Determine file type from content and metadata + """ + content_preview = content_bytes[:10] if content_bytes else b'' + + is_pdf = content_preview.startswith(b'%PDF') or 'pdf' in content_type + is_excel = any(indicator in content_type for indicator in ['spreadsheet', 'excel', 'xlsx', 'xls']) + is_csv = 'csv' in content_type or url.lower().endswith('.csv') + is_docx = 'word' in content_type or 'document' in content_type or 'officedocument.wordprocessing' in content_type + is_txt = not any([is_pdf, is_excel, is_csv, is_docx]) + + logger.info(f"File type - PDF: {is_pdf}, Excel: {is_excel}, CSV: {is_csv}, DOCX: {is_docx}, TXT: {is_txt}") + + return is_pdf, is_excel, is_csv, is_docx, is_txt \ No newline at end of file diff --git a/chatbot/utils/knowledge_service/reports_utils.py b/chatbot/utils/knowledge_service/reports_utils.py new file mode 100644 index 0000000..64bb45e --- /dev/null +++ b/chatbot/utils/knowledge_service/reports_utils.py @@ -0,0 +1,77 @@ +from io import BytesIO +from openpyxl import Workbook +from openpyxl.styles import Alignment +import logging + +logger = logging.getLogger("django") + +def generate_xlsx_from_json(data, sheet_name="Project Report"): + """ + Excel structure aligned with PDF sections. + Sources are rendered as plain text with visible URLs. + """ + + try: + if isinstance(data, dict): + data = [data] + + if not data or not isinstance(data, list): + raise ValueError("Invalid or empty data for Excel generation") + + item = data[0] + + wb = Workbook() + ws = wb.active + ws.title = sheet_name + + headers = [ + "Project Title", + "Problem Statement", + "Objective", + "Timeline", + "Action Steps", + "Sources", + ] + + ws.append(headers) + + row = [] + + for header in headers: + value = item.get(header, "") + if isinstance(value, list): + value = "\n".join( + f"{idx + 1}. {str(v)}" + for idx, v in enumerate(value) + ) + elif isinstance(value, dict): + value = "" + + row.append(value) + + ws.append(row) + + for column_cells in ws.columns: + max_length = 0 + col_letter = column_cells[0].column_letter + + for cell in column_cells: + cell.alignment = Alignment( + wrap_text=True, + vertical="top" + ) + + if cell.value: + max_length = min(max(len(str(cell.value)), max_length), 50) + + ws.column_dimensions[col_letter].width = max_length + 2 + + output = BytesIO() + wb.save(output) + output.seek(0) + + return output + + except Exception as e: + logger.error("Error generating Excel: %s", e, exc_info=True) + raise diff --git a/chatbot/utils/llm.py b/chatbot/utils/llm.py new file mode 100644 index 0000000..a7a7067 --- /dev/null +++ b/chatbot/utils/llm.py @@ -0,0 +1,87 @@ +from litellm import completion +from typing import List, Optional, Union +from chatbot.models.enums import LLMProvider +import os + + +class LLM: + def __init__( + self, + model: str, + provider: str = "", + temperature: Optional[float] = None, + llm_env_conf: Optional[dict] = None, + top_p: Optional[float] = None, + function_call: Optional[str] = None, + tool_choice: Optional[Union[str, dict]] = None, + aws_bedrock_runtime_endpoint: Optional[str] = "https://bedrock-runtime.us-west-2.amazonaws.com", + max_tokens: Optional[int] = None + ): + if not isinstance(model, str): + raise Exception("model name should be of type string.") + + if provider != LLMProvider.OPENAI and provider != "": + self.model = provider + "/" + model + else: + self.model = model + self.llm_env_conf = llm_env_conf + self.temperature = temperature + self.top_p = top_p + self.function_call = function_call + self.tool_choice = tool_choice + self.aws_bedrock_runtime_endpoint = aws_bedrock_runtime_endpoint + self.max_tokens = max_tokens + + # set the envs here + self.aws_region_name = "us-west-2" + self.aws_access_key_val = os.environ["AWS_ACCESS_KEY_ID"] + self.aws_secret_access_key_val = os.environ["AWS_SECRET_ACCESS_KEY"] + self.api_key = os.environ["OPENAI_API_KEY"] + + if llm_env_conf is None: + return + + if llm_env_conf.get("AWS_REGION") is not None: + self.aws_region_name = llm_env_conf["AWS_REGION"] + + if llm_env_conf.get("AWS_ACCESS_KEY_ID") is not None: + self.aws_access_key_val = llm_env_conf["AWS_ACCESS_KEY_ID"] + + if llm_env_conf.get("AWS_SECRET_ACCESS_KEY") is not None: + self.aws_secret_access_key_val = llm_env_conf["AWS_SECRET_ACCESS_KEY"] + + if llm_env_conf.get("OPENAI_API_KEY") is not None: + self.api_key = llm_env_conf["OPENAI_API_KEY"] + + def prompt( + self, + messages: List, + tools: Optional[List[dict]] = None, + ): + return completion( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + top_p=self.top_p, + function_call=self.function_call, + tools=tools, + messages=messages, + aws_bedrock_runtime_endpoint=self.aws_bedrock_runtime_endpoint, + aws_region_name=self.aws_region_name, + aws_access_key_id=self.aws_access_key_val, + aws_secret_access_key=self.aws_secret_access_key_val, + api_key=self.api_key + ) + + def load_env_to_dict(value: Optional[str]) -> dict: + if value is None: + return {} + env_dict = {} + env_lines = value.split("\n") + for line in env_lines: + line = line.strip() + # Ignore empty lines and comments + if line and not line.startswith("#"): + key, value = line.split("=", 1) + env_dict[key.strip()] = value.strip().strip('"').strip("'") + return env_dict diff --git a/chatbot/utils/media_preview/__init__.py b/chatbot/utils/media_preview/__init__.py new file mode 100644 index 0000000..0329ef3 --- /dev/null +++ b/chatbot/utils/media_preview/__init__.py @@ -0,0 +1,3 @@ +from .dispatcher import ThumbnailGenerator + +__all__ = ['ThumbnailGenerator'] diff --git a/chatbot/utils/media_preview/base.py b/chatbot/utils/media_preview/base.py new file mode 100644 index 0000000..985f9b6 --- /dev/null +++ b/chatbot/utils/media_preview/base.py @@ -0,0 +1,58 @@ +from abc import ABC, abstractmethod +from PIL import Image, ImageDraw, ImageFont +from .constants import THUMB_SIZE, DEFAULT_IMG_SIZE, DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, TEXT_PADDING +import logging + +logger = logging.getLogger('django') + + +class BasePreviewGenerator(ABC): + """Base class for all preview generators""" + + def __init__(self, file_path): + self.file_path = file_path + self.img_size = DEFAULT_IMG_SIZE + self.bg_color = DEFAULT_BG_COLOR + self.text_color = DEFAULT_TEXT_COLOR + + @abstractmethod + def extract_content(self): + """Extract content from the file to be rendered""" + pass + + def create_text_image(self, text, max_chars=1200): + """Create an image with text content""" + img = Image.new("RGB", self.img_size, self.bg_color) + draw = ImageDraw.Draw(img) + + display_text = text[:max_chars] + if len(text) > max_chars: + display_text += "..." + + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) + except: + font = ImageFont.load_default() + + draw.text((TEXT_PADDING, TEXT_PADDING), display_text, fill=self.text_color, font=font) + return img + + def create_thumbnail(self, img, size=THUMB_SIZE): + """Create thumbnail from image - returns a new image object""" + thumb = img.copy() + thumb.thumbnail(size) + return thumb + + def generate(self): + """Main method to generate preview image""" + try: + content = self.extract_content() + img = self.create_image(content) + return img + except Exception as e: + logger.error(f"Error generating preview for {self.file_path}: {str(e)}") + return None + + def create_image(self, content): + """Create image from content - can be overridden""" + return self.create_text_image(content) diff --git a/chatbot/utils/media_preview/constants.py b/chatbot/utils/media_preview/constants.py new file mode 100644 index 0000000..95610eb --- /dev/null +++ b/chatbot/utils/media_preview/constants.py @@ -0,0 +1,6 @@ +THUMB_SIZE = (400, 400) +DEFAULT_IMG_SIZE = (600, 400) +DEFAULT_BG_COLOR = "white" +DEFAULT_TEXT_COLOR = "black" +TEXT_PADDING = 20 +LINE_HEIGHT = 30 diff --git a/chatbot/utils/media_preview/dispatcher.py b/chatbot/utils/media_preview/dispatcher.py new file mode 100644 index 0000000..a71f422 --- /dev/null +++ b/chatbot/utils/media_preview/dispatcher.py @@ -0,0 +1,67 @@ +import os +import logging +from .generators import ( + PDFPreviewGenerator, + DocxPreviewGenerator, + XlsxPreviewGenerator, + MarkdownPreviewGenerator, + CSVPreviewGenerator, + TxtPreviewGenerator +) +from .constants import THUMB_SIZE + +logger = logging.getLogger('django') + + +class ThumbnailGenerator: + """Main dispatcher for thumbnail generation""" + + GENERATORS = { + 'pdf': PDFPreviewGenerator, + 'docx': DocxPreviewGenerator, + 'xlsx': XlsxPreviewGenerator, + 'xls': XlsxPreviewGenerator, + 'md': MarkdownPreviewGenerator, + 'markdown': MarkdownPreviewGenerator, + 'csv': CSVPreviewGenerator, + 'txt': TxtPreviewGenerator, + 'text': TxtPreviewGenerator, + } + + @classmethod + def register_generator(cls, extension, generator_class): + """Register a new generator for a file extension""" + cls.GENERATORS[extension.lower()] = generator_class + + @classmethod + def generate_preview(cls, file_path, thumbnail=False, thumb_size=THUMB_SIZE): + """ + Generate preview image for a file + """ + try: + ext = os.path.splitext(file_path)[1][1:].lower() + + if ext not in cls.GENERATORS: + logger.info(f"No generator found for extension: {ext}") + return None + + generator_class = cls.GENERATORS[ext] + generator = generator_class(file_path) + + img = generator.generate() + + if img and thumbnail: + img = generator.create_thumbnail(img, thumb_size) + + return img + + except Exception as e: + logger.error(f"Error in preview generation for {file_path}: {str(e)}") + return None + + @classmethod + def generate_thumbnail(cls, file_path, thumb_size=THUMB_SIZE): + """ + Convenience method to generate thumbnail directly + """ + return cls.generate_preview(file_path, thumbnail=True, thumb_size=thumb_size) diff --git a/chatbot/utils/media_preview/excel_service.py b/chatbot/utils/media_preview/excel_service.py new file mode 100644 index 0000000..b0942b4 --- /dev/null +++ b/chatbot/utils/media_preview/excel_service.py @@ -0,0 +1,127 @@ +from chatbot.utils.knowledge_service.reports_utils import generate_xlsx_from_json +from chatbot.utils.S3.s3_service import upload_media + + +def handle_duplicate_links(sources_list: list) -> list: + if not sources_list or not isinstance(sources_list, list): + return [] + + seen = set() + unique_sources = [] + + for source in sources_list: + if isinstance(source, dict): + key = source.get("url") + if key and key not in seen: + seen.add(key) + unique_sources.append(source) + + elif isinstance(source, str): + key = source.strip() + if key and key not in seen: + seen.add(key) + unique_sources.append(source) + + return unique_sources + + +def format_sources_for_excel(sources_list: list) -> list: + + formatted = [] + + for source in sources_list: + if isinstance(source, dict): + title = source.get("title", "") + url = source.get("url", "") + if title and url: + formatted.append(f"{title} - {url}") + elif url: + formatted.append(url) + + elif isinstance(source, str): + formatted.append(source) + + return formatted + + +def generate_excel_file( + *, + project_title: str, + author_name: str, + location: str, + timeline: str, + user_problem_statement: str, + project_objective: str, + user_action_steps, + sources_list: list, +): + cleaned_sources = handle_duplicate_links(sources_list) + + excel_sources = format_sources_for_excel(cleaned_sources) + + excel_data = { + "Project Title": project_title, + "Author": author_name, + "Location": location, + "Timeline": timeline, + "Problem Statement": user_problem_statement, + "Objective": project_objective, + "Action Steps": ( + "\n".join( + f"{idx + 1}. {step}" + for idx, step in enumerate(user_action_steps) + ) + if isinstance(user_action_steps, list) + else user_action_steps + ), + "Sources": excel_sources, + } + + excel_file = generate_xlsx_from_json(excel_data) + + excel_filename = f"{project_title}.xlsx" if project_title else "Project_Report.xlsx" + excel_filename = "".join( + c for c in excel_filename if c.isalnum() or c in (" ", "-", "_", ".") + ).replace(" ", "_") + + return { + "file": excel_file, + "file_name": excel_filename, + } + + +def generate_and_upload_excel( + *, + project_id: int, + project_title: str, + author_name: str, + location: str, + timeline: str, + user_problem_statement: str, + project_objective: str, + user_action_steps, + sources_list: list, +): + excel_generation_result = generate_excel_file( + project_title=project_title, + author_name=author_name, + location=location, + timeline=timeline, + user_problem_statement=user_problem_statement, + project_objective=project_objective, + user_action_steps=user_action_steps, + sources_list=sources_list, + ) + + excel_media = upload_media( + project_id=project_id, + media_type="excel", + file_name=excel_generation_result["file_name"], + file_content=excel_generation_result["file"].read(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + if not excel_media: + return [] + + return [excel_media] diff --git a/chatbot/utils/media_preview/generators.py b/chatbot/utils/media_preview/generators.py new file mode 100644 index 0000000..e75436e --- /dev/null +++ b/chatbot/utils/media_preview/generators.py @@ -0,0 +1,199 @@ +from pdf2image import convert_from_path +from docx import Document +import markdown +import csv +from PIL import Image, ImageDraw, ImageFont +from openpyxl import load_workbook +from .base import BasePreviewGenerator + + +class PDFPreviewGenerator(BasePreviewGenerator): + """Generate preview from PDF first page""" + + def extract_content(self): + return convert_from_path(self.file_path, first_page=1, last_page=1)[0] + + def create_image(self, content): + return content # PDF already returns an image + + +class DocxPreviewGenerator(BasePreviewGenerator): + """Generate preview from DOCX document""" + + def extract_content(self): + doc = Document(self.file_path) + return "\n".join(p.text for p in doc.paragraphs[:8]) + + +class XlsxPreviewGenerator(BasePreviewGenerator): + """Generate visual preview from Excel spreadsheet""" + + CELL_WIDTH = 100 + CELL_HEIGHT = 25 + FONT_SIZE = 11 + GRID_COLOR = (200, 200, 200) + HEADER_BG = (242, 242, 242) + TEXT_COLOR = (0, 0, 0) + BG_COLOR = (255, 255, 255) + PADDING = 5 + + def extract_content(self): + """Extract spreadsheet data with formatting""" + wb = load_workbook(self.file_path, data_only=True) + sheet = wb.active + + data = [] + max_row = min(20, sheet.max_row or 20) + max_col = min(10, sheet.max_column or 10) + + for row_idx in range(1, max_row + 1): + row_data = [] + for col_idx in range(1, max_col + 1): + cell = sheet.cell(row_idx, col_idx) + + value = str(cell.value) if cell.value is not None else "" + + bg_color = self.BG_COLOR + if cell.fill and cell.fill.start_color: + rgb = cell.fill.start_color.rgb + if rgb and isinstance(rgb, str) and len(rgb) >= 6: + if rgb not in ['00000000', 'FF000000', '00']: + try: + if len(rgb) == 8: + rgb = rgb[2:] + bg_color = tuple(int(rgb[i:i + 2], 16) for i in (0, 2, 4)) + except: + pass + + text_color = self.TEXT_COLOR + if cell.font and cell.font.color: + rgb = cell.font.color.rgb + if rgb and isinstance(rgb, str) and len(rgb) >= 6: + if rgb not in ['00000000', 'FF000000', '00']: + try: + if len(rgb) == 8: + rgb = rgb[2:] + text_color = tuple(int(rgb[i:i + 2], 16) for i in (0, 2, 4)) + except: + pass + + is_bold = cell.font.bold if cell.font else False + + row_data.append({ + 'value': value, + 'bg_color': bg_color, + 'text_color': text_color, + 'bold': is_bold + }) + + data.append(row_data) + + return data + + def create_image(self, content): + """Create visual representation of spreadsheet""" + if not content: + return None + + rows = len(content) + cols = len(content[0]) if content else 0 + + width = cols * self.CELL_WIDTH + 1 + height = rows * self.CELL_HEIGHT + 1 + + img = Image.new('RGB', (width, height), self.BG_COLOR) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", self.FONT_SIZE) + except: + try: + font = ImageFont.truetype("arial.ttf", self.FONT_SIZE) + except: + font = ImageFont.load_default() + + for row_idx, row in enumerate(content): + for col_idx, cell_data in enumerate(row): + x = col_idx * self.CELL_WIDTH + y = row_idx * self.CELL_HEIGHT + + bg_color = cell_data.get('bg_color', self.BG_COLOR) + + if row_idx == 0: + bg_color = self.HEADER_BG + + draw.rectangle( + [x, y, x + self.CELL_WIDTH, y + self.CELL_HEIGHT], + fill=bg_color, + outline=self.GRID_COLOR + ) + + value = cell_data['value'] + if value: + if len(value) > 15: + value = value[:12] + "..." + + text_color = cell_data.get('text_color', self.TEXT_COLOR) + + try: + bbox = draw.textbbox((0, 0), value, font=font) + text_height = bbox[3] - bbox[1] + except: + text_height = self.FONT_SIZE + + text_x = x + self.PADDING + text_y = y + (self.CELL_HEIGHT - text_height) // 2 + + draw.text( + (text_x, text_y), + value, + fill=text_color, + font=font + ) + + return img + + +class MarkdownPreviewGenerator(BasePreviewGenerator): + """Generate preview from Markdown file""" + + def extract_content(self): + with open(self.file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Convert markdown to plain text (strip HTML tags) + html = markdown.markdown(content) + text = html.replace("<", "").replace(">", "") + return text + + +class CSVPreviewGenerator(BasePreviewGenerator): + """Generate preview from CSV file""" + + def extract_content(self): + lines = [] + try: + with open(self.file_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + for i, row in enumerate(reader): + if i >= 10: # Limit to first 10 rows + break + line = " | ".join(str(cell) for cell in row[:5]) # First 5 columns + lines.append(line) + except Exception as e: + lines.append(f"Error reading CSV: {str(e)}") + + return "\n".join(lines) + + +class TxtPreviewGenerator(BasePreviewGenerator): + """Generate preview from plain text file""" + + def extract_content(self): + try: + with open(self.file_path, 'r', encoding='utf-8') as f: + return f.read() + except UnicodeDecodeError: + # Try with a different encoding if UTF-8 fails + with open(self.file_path, 'r', encoding='latin-1') as f: + return f.read() diff --git a/chatbot/utils/media_preview/media_creation.py b/chatbot/utils/media_preview/media_creation.py new file mode 100644 index 0000000..9ce684d --- /dev/null +++ b/chatbot/utils/media_preview/media_creation.py @@ -0,0 +1,312 @@ +import os +import logging + +from chatbot.models import CompanyBot +from chatbot.models.story_vernacular_model import StoryVernacular +from chatbot.utils.S3.s3_service import upload_file_to_s3 +from chatbot.utils.gotenberg_utils import generate_pdf_with_gotenberg +from chatbot.models.enums import MediaTypeChoices + +logger = logging.getLogger('django') + + +def create_pdf_from_text(text_content, company_bot_id) -> bytes: + """ + Create a PDF file from text content using Gotenberg HTML-to-PDF service. + """ + try: + # Convert text to formatted HTML + html_content = text_to_html(text_content, company_bot_id) + + # Use Gotenberg to convert HTML to PDF + pdf_content = generate_pdf_with_gotenberg(html_content) + + if not pdf_content: + raise Exception("Gotenberg failed to generate PDF") + + logger.info(f"Successfully created PDF with {len(text_content)} characters") + + return pdf_content + + except Exception as e: + logger.error(f"Error creating PDF from text: {e}", exc_info=True) + raise + + +def text_to_html(text_content, company_bot_id) -> str: + """ + Convert Markdown text to styled HTML for PDF generation. + """ + import markdown + import re + + max_char_per_page = 1500 + try: + print(f"company_bot_id: {company_bot_id}") + logger.info(f"company_bot_id: {company_bot_id}") + company_bot = CompanyBot.objects.filter(id=company_bot_id).first() + print(f"company_bot: {company_bot}") + logger.info(f"company_bot: {company_bot}") + if company_bot: + story_vernacular = StoryVernacular.objects.filter( + company_bot=company_bot, language='en' + ).first() + print(f"story_vernacular: {story_vernacular}") + logger.info(f"story_vernacular: {story_vernacular}") + if story_vernacular and story_vernacular.translation_json: + logger.info(f"story_vernacular translation_json: {story_vernacular.translation_json}") + max_char_per_page = story_vernacular.translation_json.get( + 'page_split_char_len', max_char_per_page + ) + logger.info(f"Using max_char_per_page: {max_char_per_page}") + except Exception as e: + logger.info(f"Could not get max_char_per_page from StoryVernacular: {e}") + + # Markdown → HTML + html = markdown.markdown( + text_content, + extensions=[ + "tables", + "fenced_code", + "sane_lists", + "toc", + "def_list" + ] + ) + + # Regex to find block-level elements (with attributes allowed) + block_pattern = re.compile( + r'(]*>.*?|' + r']*>.*?

      |' + r']*>.*?|' + r']*>.*?
    |' + r']*>.*?)', + flags=re.DOTALL + ) + + pages = [] + current_page = "" + char_count = 0 + last_index = 0 + + def is_heading(block: str) -> bool: + return block.lstrip().startswith(" bool: + return block.lstrip().startswith(("= max_char_per_page and is_content_block(block): + pages.append(current_page) + current_page = "" + char_count = 0 + + last_index = end + + # Append remaining tail content + tail = html[last_index:] + if tail: + current_page += tail + + if current_page.strip(): + pages.append(current_page) + + # Wrap pages + paginated_html = "" + for page in pages: + paginated_html += f""" +
    + {page} +
    + """ + + html_template = f""" + + + + + + + + {paginated_html} + + + """ + + return html_template + + +def sanitize_filename(filename: str) -> str: + """ + Sanitize filename and ensure it has .pdf extension. + """ + try: + # Remove any path separators + filename = os.path.basename(filename) + + # Remove extension if present + name_without_ext = os.path.splitext(filename)[0] + + # Replace any invalid characters + safe_name = "".join(c for c in name_without_ext if c.isalnum() or c in (' ', '-', '_')) + + # Remove extra spaces and replace with underscores + safe_name = '_'.join(safe_name.split()) + + # Ensure it's not empty + if not safe_name: + safe_name = "download" + + # Add .pdf extension + return f"{safe_name}.pdf" + + except Exception as e: + logger.error(f"Error sanitizing filename: {e}") + return "download.pdf" + + +def create_and_upload_file( + *, + content: str, + filename: str, + company_bot_id: int, + session_id: str +) -> dict: + """ + Create a PDF file from content and upload it to S3. + """ + try: + logger.info(f"Creating file for session {session_id}, company_bot {company_bot_id}") + logger.info(f"Original filename: {filename}, content length: {len(content)} chars") + + # Sanitize filename and ensure .pdf extension + safe_filename = sanitize_filename(filename) + logger.info(f"Sanitized filename: {safe_filename}") + + # Create PDF from content using Gotenberg + pdf_content = create_pdf_from_text(content, company_bot_id) + + logger.info(f"PDF created successfully, size: {len(pdf_content)} bytes") + + # Prepare folder structure: chatbot// + folder_structure = f"chatbot/{company_bot_id}/" + + # Upload to S3 + s3_key = upload_file_to_s3( + file_name=safe_filename, + file_content=pdf_content, + content_type=MediaTypeChoices.PDF, + project_id=None, + folder_structure=folder_structure + ) + + if not s3_key: + logger.error("Failed to upload file to S3") + return { + 'success': False, + 'error': 'Failed to upload file to S3' + } + + # Construct media URL + base = os.getenv("S3_MEDIA_URL") + media_url = f"{base}{s3_key}" + + logger.info(f"File uploaded successfully: {media_url}") + + return { + 'success': True, + 'media_url': media_url, + 'file_name': safe_filename, + 's3_key': s3_key + } + + except Exception as e: + logger.error(f"Error creating and uploading file: {e}", exc_info=True) + return { + 'success': False, + 'error': str(e) + } diff --git a/chatbot/utils/media_utils.py b/chatbot/utils/media_utils.py new file mode 100644 index 0000000..091abfe --- /dev/null +++ b/chatbot/utils/media_utils.py @@ -0,0 +1,189 @@ +import base64 +import os +import requests +from chatbot.models import StoryMedia, MediaTypeChoices +from django.db.models import Q +from django.db import transaction + + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def get_file_names_for_story(story): + """Retrieve file names for a specific story.""" + return StoryMedia.objects.filter(story=story, media_type=MediaTypeChoices.PDF).first() + + +def get_file_names_for_session(session_value): + """Retrieve file names for a specific session.""" + return StoryMedia.objects.filter( + Q(story__session=session_value, include_in_story=True) | + Q(story__session=session_value, media_type=MediaTypeChoices.PDF) + ) + + +def prepare_request_data(session_value, file_names): + """Prepare request data for the cloud service.""" + return { + "request": { + session_value: { + "files": file_names + } + } + } + + +def perform_cloud_upload(file_info, pdf_file): + """Upload a file to the cloud using base64-encoded data.""" + presigned_url = file_info["url"] + + response = requests.put( + presigned_url, + data=pdf_file, + headers={"Content-Type": "multipart/form-data", "x-ms-blob-type": "BlockBlob",} + ) + print(response) + print(response.status_code) + if response.status_code in [200, 201]: + print(f"File uploaded successfully") + return True + else: + print(f"Failed to upload file: {response.status_code}, {response.text}") + return False + + +def update_story_media_source_path(file_name, source_path): + """Update the source path for a file in the database.""" + StoryMedia.objects.filter(name=file_name).update(source_path=source_path) + + +def handle_cloud_response(results, session_value, story=None, instance=None): + """Handle the cloud response and save data.""" + attachments = [] + pdf_information = [] + + if story: + for file_info in results.get(session_value, {}).get("files", []): + source_path = file_info["payload"]["sourcePath"] + update_story_media_source_path(file_info["file"], source_path) + story_media_file = get_file_names_for_story(story) + print("story_media_file: ", story_media_file) + if not story_media_file: + print(f"No story file found for story ID: {story.id}") + else: + print(f"Story file: {story_media_file.name}") + pdf_file = story_media_file.file + if not pdf_file: + print(f"No pdf_file found for story ID: {story.id}") + else: + print(f"Pdf file: {pdf_file}") + print("pdf_file type: ", type(pdf_file)) + if perform_cloud_upload(file_info, pdf_file): + pdf_information.append({ + "filePath": source_path, + "language": story.language + }) + + return {"attachments": attachments, "pdfInformation": pdf_information} + + if instance: + file_name = instance.get("name") + file_info = results.get(session_value, {}).get("files", [])[0] + print("file_info: ", file_info) + source_path = file_info["payload"]["sourcePath"] + file_data = instance.get("base64_str") + print('source path: ', source_path) + + binary_data = base64.b64decode(file_data) + + update_story_media_source_path(file_name, source_path) + print('file_info["url"]: ', file_info["url"]) + + response = requests.put( + file_info["url"], + data=binary_data, + headers={ + "Content-Type": "multipart/form-data", + "Access-Control-Allow-Origin": "*", + "x-ms-blob-type": "BlockBlob", + } + ) + print('cloud response: ', response) + + if response.status_code == 200: + print(f"File uploaded successfully: {file_name}") + else: + print(f"Failed to upload file {file_name}: {response.status_code}, {response.text}") + + attachments.append({ + "name": file_name, + "sourcePath": source_path, + "type": instance.get("media_type") + }) + return {"attachments": attachments, "pdfInformation": pdf_information} + + for session_id, session_data in results.items(): + if session_id == "cloudStorage": + continue + for file_info in session_data.get("files", []): + file_name = file_info["file"] + source_path = file_info["payload"]["sourcePath"] + base64_data = None + file_record = StoryMedia.objects.filter(name=file_name).first() + + if file_record: + update_story_media_source_path(file_name, source_path) + base64_data = file_record.base64_str + + if base64_data and perform_cloud_upload(file_info, base64_data): + if file_name.lower().endswith(".pdf"): + pdf_information.append({ + "filePath": source_path, + "language": story.language + }) + else: + attachments.append({ + "name": file_name, + "sourcePath": source_path, + "type": file_record.media_type + }) + + return {"attachments": attachments, "pdfInformation": pdf_information} + + +def upload_to_cloud(session_value, access_token, story=None, instance=None): + """Main function to upload files to the cloud.""" + url = f"https://{base_url}/cloud-services/files/preSignedUrls" + print("---------------------") + print("Upload Cloud Url: ", url) + print("---------------------") + if access_token.startswith('"') and access_token.endswith('"'): + access_token = access_token[1:-1] + headers = {"X-auth-token": access_token} + + if story: + story_file_name = get_file_names_for_story(story) + file_names = [story_file_name.name if story_file_name else "sample"] + elif instance: + if not instance.get("include_in_story") and instance.get("media_type") != MediaTypeChoices.PDF: + return + file_names = [instance.get("name")] + else: + file_names = [file.name for file in get_file_names_for_session(session_value)] + + data = prepare_request_data(session_value, file_names) + print("presigned: upload data: ", data) + response = None + try: + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + print('response text: ', response.json()) + + results = response.json().get("result", {}) + with transaction.atomic(): + return handle_cloud_response(results, session_value, story=story, instance=instance) + except requests.exceptions.RequestException as e: + print(f"Error during cloud request: {e}") + response.raise_for_status() + return None diff --git a/chatbot/utils/mitra_bedrock_tool_call.py b/chatbot/utils/mitra_bedrock_tool_call.py new file mode 100644 index 0000000..d283058 --- /dev/null +++ b/chatbot/utils/mitra_bedrock_tool_call.py @@ -0,0 +1,69 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import ChatSession, ChatStatus, CompanyChat +from chatbot.models.company_models import CompanyStateMachine + + +channel_layer = get_channel_layer() + + +def get_mitra_bedrock_tool_response( + system_prompt, messages, company_bot, session_id, channel_name, route, profile_id +): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + company_chat = CompanyChat.objects.filter(session=session_id) + print("Length: ", len(company_chat)) + chunks = [] + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, company_bot=company_bot + ) + print("response_body bedrock: ", response) + + is_function_call = False + if isinstance(response, dict): + tool_use_id = response.get('toolUseId', None) + if tool_use_id: + is_function_call = True + print("is_function_call: ", is_function_call) + + if is_function_call: + print("its func call") + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + save_in_company_db( + session_id, profile_id, 'AI', bot_question, chunks, chat_status, translated_message + ) + return response + else: + print("its not a func call") + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id, profile_id, 'AI', response, chunks, ChatStatus.IN_PROGRESS, translated_message + ) + + return response diff --git a/chatbot/utils/one_shot_bedrock_tool_call.py b/chatbot/utils/one_shot_bedrock_tool_call.py new file mode 100644 index 0000000..922c46e --- /dev/null +++ b/chatbot/utils/one_shot_bedrock_tool_call.py @@ -0,0 +1,136 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, LLMProvider +from chatbot.models.company_models import CompanyStateMachine +import logging + + +logger = logging.getLogger('django') + +channel_layer = get_channel_layer() + + +def get_one_shot_bedrock_tool_call_response(system_prompt, messages, company_bot, session_id, channel_name, + route, profile_id, remaining_stages, temp_messages, intro_mssg=None): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + chunks = [] + + if (intro_mssg is None and len(messages) < 2) or (intro_mssg is not None and len(messages) <= 3): + current_stage_name = remaining_stages[0] + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, name=current_stage_name) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + save_in_company_db( + session_id, profile_id, 'AI', bot_question, chunks, ChatStatus.IN_PROGRESS, translated_message + ) + print("asking first bot_question: ", bot_question) + return bot_question + + response = None + message_to_send = temp_messages if temp_messages else messages + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, messages=message_to_send, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + tools = [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + response = handle_openai_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tools, tool_choice='auto', is_json_response=False + ) + + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + print("Response: ", response) + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + + if is_function_call and remaining_stages: + + remaining_stages.pop(0) + + current_stage_name = remaining_stages[0] + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, name=current_stage_name) + chat_session.session_context['remaining_stages'] = remaining_stages + chat_session.current_step = state_machine.step + chat_session.save() + + bot_question = state_machine.bot_question + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + print("chat_status: ", chat_status) + + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=bot_question, + chunks=chunks, status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + return response + + else: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=response, + chunks=chunks, status=ChatStatus.IN_PROGRESS, translated_message=translated_message, + stage=state_machine.name + ) + + return response diff --git a/chatbot/utils/one_shot_utils.py b/chatbot/utils/one_shot_utils.py new file mode 100644 index 0000000..9f49368 --- /dev/null +++ b/chatbot/utils/one_shot_utils.py @@ -0,0 +1,123 @@ +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from jinja2 import Template +from chatbot.utils.chat_utils import get_guided_chat +import logging + + +logger = logging.getLogger('django') + + +def get_remaining_strands(messages, company_chats, oneshot_bot, profile, intro=None, other_info=None, extra_params=None): + + assistant_route = extra_params.get('assistant_route','/oneshot_assistant') + validator_route = extra_params.get('validator_route','/oneshot_validator') + if profile: + company_bot = CompanyBot.objects.filter(company=profile.company, route=assistant_route).first() + validate_bot = CompanyBot.objects.filter(company=profile.company, route=validator_route).first() + else: + company_bot = CompanyBot.objects.filter(route=assistant_route).first() + validate_bot = CompanyBot.objects.filter(route=validator_route).first() + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + one_shot_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=company_bot.context) + if oneshot_bot.provider != company_bot.provider: + print("provider is not same so changing message!") + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats, intro=intro, other_info=other_info + ) + response = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=one_shot_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, tools=tool, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + # openai_tool = convert_llama_to_openai_tool(llama_tool_call=tool) + response = handle_openai_model( + system_prompt=one_shot_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + ) + + if response: + if response.get('parameters'): + response = response.get('parameters') + elif response.get('input'): + response = response.get('input') + + tool = validate_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + context_data = { + "oneshot_assistant_response": response + } + template = Template(validate_bot.tag_context) + tag_context = template.render(context_data) + content_prompt = f""" + {validate_bot.context} + {tag_context} + """ + one_shot_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=content_prompt) + + if oneshot_bot.provider != validate_bot.provider: + messages = get_guided_chat( + company_bot=validate_bot, company_chats=company_chats, intro=intro, other_info=other_info + ) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=one_shot_prompt, messages=messages, model_name=validate_bot.llm_model, + temperature=validate_bot.bot_temperature, max_token=validate_bot.max_token, tools=tool, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = { + "error": "I am sorry, I could not understood completely. Could you rephrase this please?" + } + elif company_bot.provider == LLMProvider.OPENAI: + # openai_tool = convert_llama_to_openai_tool(llama_tool_call=tool) + response = handle_openai_model( + system_prompt=one_shot_prompt, messages=messages, model_name=validate_bot.llm_model, + temperature=validate_bot.bot_temperature, max_token=validate_bot.max_token, + ) + + + if response: + if response.get('parameters'): + response = response.get('parameters') + elif response.get('input'): + response = response.get('input') + + return response + + +def get_assistant_prompt(company_bot, content_prompt): + prompt_to_use=[] + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + prompt_to_use = [ + { + 'text': content_prompt + } + ] + elif company_bot.provider == LLMProvider.OPENAI: + prompt_to_use = [ + { + 'role': 'system', + 'content': content_prompt + } + ] + + return prompt_to_use diff --git a/chatbot/utils/oneshot_guest_tool_call.py b/chatbot/utils/oneshot_guest_tool_call.py new file mode 100644 index 0000000..10bb6dd --- /dev/null +++ b/chatbot/utils/oneshot_guest_tool_call.py @@ -0,0 +1,136 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import ChatSession, ChatStatus, LLMProvider +from chatbot.models.company_models import CompanyStateMachine +import logging + + +logger = logging.getLogger('django') + +channel_layer = get_channel_layer() + + +def get_oneshot_guest_tool_call_response(system_prompt, messages, company_bot, session_id, channel_name, + route, profile_id, remaining_stages, temp_messages, intro_mssg=None): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + chunks = [] + + if (intro_mssg is None and len(messages) < 2) or (intro_mssg is not None and len(messages) <= 3): + current_stage_name = remaining_stages[0] + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, name=current_stage_name) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + save_in_company_db( + session_id, profile_id, 'AI', bot_question, chunks, ChatStatus.IN_PROGRESS, translated_message + ) + print("asking first bot_question: ", bot_question) + return bot_question + + response = None + message_to_send = temp_messages if temp_messages else messages + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=system_prompt, messages=message_to_send, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + tools = [ + { + "type": "function", + "function": { + "name": "get_state_information", + "description": "Get the information of the state you want to be in.", + "parameters": { + "type": "object", + "properties": { + "state_name": { + "type": "string", + "description": "Name of the next state provided in the context." + } + }, + "required": ["state_name"] + } + } + } + ] + response = handle_openai_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tools, tool_choice='auto', is_json_response=False + ) + + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + print("Response: ", response) + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + + if is_function_call and remaining_stages: + + remaining_stages.pop(0) + + current_stage_name = remaining_stages[0] + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, name=current_stage_name) + chat_session.session_context['remaining_stages'] = remaining_stages + chat_session.current_step = state_machine.step + chat_session.save() + + bot_question = state_machine.bot_question + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + print("chat_status: ", chat_status) + + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=bot_question, + chunks=chunks, status=chat_status, translated_message=translated_message, stage=state_machine.name + ) + return response + + else: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id=session_id, profile_id=profile_id, initiated_by='AI', message=response, + chunks=chunks, status=ChatStatus.IN_PROGRESS, translated_message=translated_message, + stage=state_machine.name + ) + + return response diff --git a/chatbot/utils/oneshot_guest_utils.py b/chatbot/utils/oneshot_guest_utils.py new file mode 100644 index 0000000..aa29da2 --- /dev/null +++ b/chatbot/utils/oneshot_guest_utils.py @@ -0,0 +1,103 @@ +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from jinja2 import Template +from chatbot.utils.chat_utils import get_guided_chat +import logging +from chatbot.utils.one_shot_utils import get_assistant_prompt + + +logger = logging.getLogger('django') + + +def get_remaining_strands(messages, company_chats, oneshot_bot, profile, intro=None, other_info=None): + + if profile: + company_bot = CompanyBot.objects.filter(company=profile.company, route='/oneshot_guest_assistant').first() + validate_bot = CompanyBot.objects.filter(company=profile.company, route='/oneshot_guest_validator').first() + else: + company_bot = CompanyBot.objects.filter(route='/oneshot_guest_assistant').first() + validate_bot = CompanyBot.objects.filter(route='/oneshot_guest_validator').first() + + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + one_shot_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=company_bot.context) + if oneshot_bot.provider != company_bot.provider: + print("provider is not same so changing message!") + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats, intro=intro, other_info=other_info + ) + response = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=one_shot_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, tools=tool, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + # openai_tool = convert_llama_to_openai_tool(llama_tool_call=tool) + response = handle_openai_model( + system_prompt=one_shot_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + ) + + if response: + if response.get('parameters'): + response = response.get('parameters') + elif response.get('input'): + response = response.get('input') + + tool = validate_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + + context_data = { + "oneshot_assistant_response": response + } + template = Template(validate_bot.tag_context) + tag_context = template.render(context_data) + content_prompt = f""" + {validate_bot.context} + {tag_context} + """ + one_shot_prompt = get_assistant_prompt(company_bot=company_bot, content_prompt=content_prompt) + + if oneshot_bot.provider != validate_bot.provider: + messages = get_guided_chat( + company_bot=validate_bot, company_chats=company_chats, intro=intro, other_info=other_info + ) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=one_shot_prompt, messages=messages, model_name=validate_bot.llm_model, + temperature=validate_bot.bot_temperature, max_token=validate_bot.max_token, tools=tool, + company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = { + "error": "I am sorry, I could not understood completely. Could you rephrase this please?" + } + elif company_bot.provider == LLMProvider.OPENAI: + # openai_tool = convert_llama_to_openai_tool(llama_tool_call=tool) + response = handle_openai_model( + system_prompt=one_shot_prompt, messages=messages, model_name=validate_bot.llm_model, + temperature=validate_bot.bot_temperature, max_token=validate_bot.max_token, + ) + + + if response: + if response.get('parameters'): + response = response.get('parameters') + elif response.get('input'): + response = response.get('input') + + return response diff --git a/chatbot/utils/profile_utils.py b/chatbot/utils/profile_utils.py new file mode 100644 index 0000000..7bd14cc --- /dev/null +++ b/chatbot/utils/profile_utils.py @@ -0,0 +1,119 @@ +import os + +import requests +from pydantic_core._pydantic_core import ValidationError + +from chatbot.models import Profile, Company +from chatbot.models.geo_models import ProfileAddress +from chatbot.serializer.profile_serializer import ProfileSerializer + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def create_profile_utils(access_token): + + try: + json_response = get_profile_detail(access_token=access_token) + + if not json_response or "result" not in json_response: + return json_response + + result = json_response.get("result") + email = result.get('email') + userid = result.get('id') + name = result.get('name') + preferred_language = result.get('preferred_language', {}).get('value') + organization = result.get('organization', {}).get('name') + block = result.get('block', {}).get('label') + state = result.get('state', {}).get('label') + district = result.get('district', {}).get('label') + user_roles = result.get('user_roles', []) + + + company = Company.objects.get(slug='shikshalokamstaging') + profile_data = { + "email": email, + "first_name": name, + "preferred_route": preferred_language, + "org_associated": organization, + "password": 'grit@123', + "company": company, + "designation": user_roles + } + + address_data = { + "block": block, + "state": state, + "district": district, + } + profile, created = Profile.objects.update_or_create( + userid=userid, + defaults=profile_data + ) + + if address_data: + ProfileAddress.objects.filter(profile=profile).delete() + + ProfileAddress.objects.update_or_create( + profile=profile, + defaults=address_data + ) + serialized_profile = ProfileSerializer(profile).data + return { + 'success': True, + 'data': serialized_profile + } + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the API call: {e}") + return { + 'success': False, + 'status_code': 500, + 'message': f"Error while making the API call: {e}" + } + except Exception as e: + print("e: ", e) + return { + 'success': False, + 'status_code': 500, + 'message': f"An unexpected error occurred: {e}" + } + + +def get_profile_detail(access_token): + url = f"https://{base_url}/profile/read" + + headers = { + "X-auth-token": access_token, + } + + try: + response = requests.get(url, headers=headers) + if response.status_code == 401: + return { + 'success': False, + 'status_code': 401, + 'message': 'Access token is invalid or expired.' + } + elif response.status_code != 200: + return { + 'success': False, + 'status_code': response.status_code, + 'message': f"API returned an error: {response.text}" + } + + json_response = response.json() + + if not json_response or "result" not in json_response: + return { + 'success': False, + 'status_code': 400, + 'message': 'Invalid response from the API.' + } + return json_response + + except Exception as e: + return { + 'success': False, + 'status_code': 500, + 'message': f"An unexpected error occurred: {e}" + } diff --git a/chatbot/utils/project_formatting_utils.py b/chatbot/utils/project_formatting_utils.py new file mode 100644 index 0000000..8d12146 --- /dev/null +++ b/chatbot/utils/project_formatting_utils.py @@ -0,0 +1,46 @@ +def normalize_sources_from_chunks(chunks): + + sources_list = [] + + if not isinstance(chunks, dict): + return sources_list + + all_sources = ( + chunks.get("objective_chunk", []) + + chunks.get("action_chunk", []) + ) + + for src in all_sources: + title = src.get("title") + url = src.get("url") + org = src.get("organization", {}).get("name") + + parts = [] + if title: + parts.append(title) + if org: + parts.append(f"({org})") + if url: + parts.append(url) + + if parts: + sources_list.append(" ".join(parts)) + + return sources_list + + +def format_project_timeline(project_duration): + + if not project_duration: + return "" + + duration_str = str(project_duration).strip().lower() + + if "week" in duration_str: + return str(project_duration).strip() + + try: + duration_value = int(project_duration) + return f"{duration_value} week" if duration_value == 1 else f"{duration_value} weeks" + except (ValueError, TypeError): + return str(project_duration) diff --git a/chatbot/utils/ptm_utils/chat_utils.py b/chatbot/utils/ptm_utils/chat_utils.py new file mode 100644 index 0000000..1ff8049 --- /dev/null +++ b/chatbot/utils/ptm_utils/chat_utils.py @@ -0,0 +1,139 @@ +from chatbot.models import ChatType, Profile, CompanyBot, ChatSession, CompanyChat, Voice, VoiceType, TextConversionType +from django.db import transaction +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.utils.transliterate_utils import transliterate_text +import logging + +logger = logging.getLogger('django') + + +def get_bot_from_flow(flow): + route = None + if flow == ChatType.megaPTM: + route = '/mega_ptm' + else: + route = '/common_bot' + + return route + + +def save_question_answer_utils( + profile_id, flow, session, sequence, status, language, question_id, + sent_at, question, translated_message, answer, audio_file, answer_id, + service +): + try: + user_translated_msg=None + with transaction.atomic(): + logger.info("Started saving Q&A for session %s", session) + ai_user = Profile.objects.get(id=1) + user_profile = Profile.objects.get(id=profile_id) + + route = get_bot_from_flow(flow=flow) + company_bot = CompanyBot.objects.filter(route=route).first() + if not company_bot: + logger.error("No bots found for route %s", route) + return {"error": "No bots found for this flow", "status": 404} + + print("language: ", language) + print("service: ", service) + if language and language != 'en' and service and answer: + if service == TextConversionType.TRANSLITERATE: + transliterate_voice_provider = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.Transliterate, + language=language + ).first() + is_sentence = ' ' in answer + response = transliterate_text( + voice_provider=transliterate_voice_provider, source_language=language, target_language='en', + message_body=answer, is_sentence=is_sentence + ) + print("Trans response: ", response) + if response and response.get('content'): + content = response.get('content') + print("Trans content: ", content) + if content and isinstance(content, list) and len(content) > 0: + content = content[0] + user_translated_msg = content + else: + voice_provider = Voice.objects.filter( + company_bot=company_bot, + type=VoiceType.TextToText, + language=language + ).first() + response = text_translate_provider( + voice_provider=voice_provider, message_body=answer, + target_language='en', source_language=language + ) + + if response.get('status') == 200: + user_translated_msg = response.get('content') + + chat_session, created = ChatSession.objects.update_or_create( + session=session, + defaults={ + "profile": user_profile, + "company_bot": company_bot, + "current_step": sequence, + "session_status": status, + "session_type": flow, + "language": language, + } + ) + logger.info("Chat session %s %s", "created" if created else "updated", session) + + other_params = { + "language": language, + "sent_at": sent_at, + "sequence": sequence, + "question_id": question_id, + } + + question_params = {**other_params, "message_type": "question"} + answer_params = {**other_params, "message_type": "answer", "answer_id": answer_id} + + CompanyChat.objects.update_or_create( + session=session, + source_msg_id=question_id, + defaults={ + "message": translated_message if language and language!='en' else question, + "status": status, + "sender": ai_user, + "receiver": user_profile, + "chunks": None, + "translated_message": question if language and language!='en' else translated_message, + "other_params": question_params, + } + ) + logger.info("Saved question with ID %s for session %s", question_id, session) + + existing_answer = CompanyChat.objects.filter( + session=session, source_msg_id=answer_id + ).first() + if existing_answer and existing_answer.file_url and audio_file: + audio_file = f"{existing_answer.file_url},{audio_file}" + + CompanyChat.objects.update_or_create( + session=session, + source_msg_id=answer_id, + defaults={ + "message": answer, + "status": status, + "sender": user_profile, + "receiver": ai_user, + "chunks": None, + "translated_message": user_translated_msg, + "other_params": answer_params, + "file_url": audio_file + } + ) + logger.info("Saved answer with source_msg_id %s for session %s", sent_at, session) + return {"message": "Message saved successfully!", "status": 200} + + except Profile.DoesNotExist: + logger.error('Profile with id %s does not exist', profile_id, exc_info=True) + return {"error": "Profile not found.", "status": 404} + except Exception as e: + logger.error('Error processing session %s: %s', session, e, exc_info=True) + return {"error": str(e), "status": 500} diff --git a/chatbot/utils/recreate_story_utils.py b/chatbot/utils/recreate_story_utils.py new file mode 100644 index 0000000..8c3e760 --- /dev/null +++ b/chatbot/utils/recreate_story_utils.py @@ -0,0 +1,108 @@ +import traceback +from chatbot.llm_models.llm_script import handle_openai_model +from chatbot.models import (Profile, CompanyChat, CompanyBot, StoryLanguageChoices, + StoryStatusChoices, LLMModel) +from chatbot.models.story_models import Story +# from chatbot.utils.story_utils import get_company_context, DEFAULT_PROMPT, get_formatted_story + + +def re_create_story_object(profile_id, session): + try: + pass + # profile = Profile.objects.get(id=profile_id) + # company = profile.company + # company_context = get_company_context(profile, company) + # company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + # print('company_chats: ', company_chats) + # if len(company_chats) <= 10: + # return "", "" + # ai_user = Profile.objects.get(id=1) + # company_bot = CompanyBot.objects.filter(company=profile.company) + # if company_bot.count() > 0: + # company_bot = company_bot[0] + # end_context = company_bot.end_context + # if end_context is None or end_context == "": + # end_context = DEFAULT_PROMPT + # else: + # end_context = DEFAULT_PROMPT + # end_context += company_context + # end_context += """ + # OUTPUT JSON FORMAT: + # {{ + # "title": "Title of the story", + # "content": "Content of the story in more than 600 tokens", + # "tweet": "Tweet for the story in less than 200 characters with minimum 5 hashtags", + # "objective": "Objective of the micro improvement", + # "action_steps": "5 Action steps taken by the user to implement the micro improvement", + # "impact": "Impact created from this micro improvement", + # "micro_improvement": "Why is this microprovement important" + # }} + # """ + # print(end_context) + # messages = [{ + # 'role': 'system', + # 'content': end_context + # }] + # for chat in company_chats: + # if chat.receiver == ai_user: + # messages.append({ + # 'role': 'user', + # 'content': chat.message + # }) + # else: + # messages.append({ + # 'role': 'assistant', + # "content": chat.message + # }) + # messages.append({ + # 'role': 'system', + # 'content': 'REMEMBER RETURN THE STORY IN MORE THAN 600 TOKENS.' + # }) + # + # response_json = handle_openai_model( + # messages=messages, max_token=4096, temperature=0.0, model_name=LLMModel.GPT4_O + # ) + # + # print(response_json) + # + # story_data = { + # 'title': response_json['title'], + # 'content': response_json['content'], + # 'tweet': response_json['tweet'], + # 'objective': response_json['objective'], + # 'action_steps': response_json['action_steps'], + # 'impact': response_json['impact'], + # 'micro_improvement': response_json['micro_improvement'] + # } + # story, created = Story.objects.update_or_create( + # session=session, + # defaults={ + # 'title': story_data['title'], + # 'content': story_data['content'], + # 'tweet': story_data['tweet'], + # 'author': profile, + # 'objective': story_data['objective'], + # 'action_steps': story_data['action_steps'], + # 'impact': story_data['impact'], + # 'micro_improvement': story_data['micro_improvement'], + # 'language': StoryLanguageChoices.ENGLISH, + # 'stage': StoryStatusChoices.COMPLETED + # } + # ) + # if not created: + # story.title = story_data['title'] + # story.content = story_data['content'] + # story.tweet = story_data['tweet'] + # story.objective = story_data['objective'] + # story.action_steps = story_data['action_steps'] + # story.impact = story_data['impact'] + # story.micro_improvement = story_data['micro_improvement'] + # story.stage = StoryStatusChoices.COMPLETED + # story.save() + # formatted_content = get_formatted_story(story) + # story.formatted_content = formatted_content + # story.save(update_fields=['formatted_content']) + # return story.id, story.content + except Exception as e: + traceback.print_exc() + return "", "" diff --git a/chatbot/utils/reflection_bedrock_tool_call.py b/chatbot/utils/reflection_bedrock_tool_call.py new file mode 100644 index 0000000..1462d69 --- /dev/null +++ b/chatbot/utils/reflection_bedrock_tool_call.py @@ -0,0 +1,74 @@ +from channels.layers import get_channel_layer +from chatbot.celery_tasks.common_chat_tasks import save_in_company_db +from chatbot.celery_tasks.handle_message import translate_and_send_message +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import ChatSession, ChatStatus, CompanyChat +from chatbot.models.company_models import CompanyStateMachine + + +channel_layer = get_channel_layer() + + +def get_reflection_bedrock_tool_response( + system_prompt, messages, company_bot, session_id, channel_name, route, profile_id +): + + chat_session = ChatSession.objects.get(session=session_id) + current_step = chat_session.current_step + company_chat = CompanyChat.objects.filter(session=session_id) + print("Length: ", len(company_chat)) + chunks = [] + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, + model_name=company_bot.llm_model, temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, company_bot=company_bot + ) + print("response_body bedrock: ", response) + if response is None: + response = 'I am sorry, I could not understood completely. Could you rephrase this please?' + + is_function_call = False + if isinstance(response, dict): + is_function_call = True + elif isinstance(response, str): + if 'get_state_information' in response: + is_function_call = True + print("is_function_call: ", is_function_call) + + if is_function_call: + print("its func call") + chat_session.current_step += 1 + chat_session.save() + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=chat_session.current_step) + bot_question = state_machine.bot_question + + translated_message = translate_and_send_message( + accumulated_message=bot_question, current_channel_name=channel_name, + current_step_number=chat_session.current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + + name_machine = state_machine.name + print("name_machine: ", name_machine) + if state_machine.name == "APPRECIATION": + chat_status = ChatStatus.COMPLETED + else: + chat_status = ChatStatus.IN_PROGRESS + + save_in_company_db( + session_id, profile_id, 'AI', bot_question, chunks, chat_status, translated_message + ) + return response + else: + print("its not a func call") + translated_message = translate_and_send_message( + accumulated_message=response, current_channel_name=channel_name, + current_step_number=current_step, finish_reason="stop", route=route, + company_bot=company_bot + ) + save_in_company_db( + session_id, profile_id, 'AI', response, chunks, ChatStatus.IN_PROGRESS, translated_message + ) + + return response diff --git a/chatbot/utils/shiksha_chaupal/__init__.py b/chatbot/utils/shiksha_chaupal/__init__.py new file mode 100644 index 0000000..8d8736e --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/__init__.py @@ -0,0 +1,13 @@ +from .iterative_challenge_processor import ( + IterativeChallengeProcessor, + run_iterative_challenge_filtering, + DEFAULT_MAX_ITERATIONS, + DEFAULT_FILTER_THRESHOLD +) + +__all__ = [ + 'IterativeChallengeProcessor', + 'run_iterative_challenge_filtering', + 'DEFAULT_MAX_ITERATIONS', + 'DEFAULT_FILTER_THRESHOLD' +] diff --git a/chatbot/utils/shiksha_chaupal/base_utils.py b/chatbot/utils/shiksha_chaupal/base_utils.py new file mode 100644 index 0000000..e19e4bd --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/base_utils.py @@ -0,0 +1,70 @@ +from chatbot.models import LLMProvider +from chatbot.utils.sql_utils import get_todays_date +from jinja2 import Template + + +def get_guided_prompt(company_bot, system_context, state_machine=None, intro_mssg=None, profile=None): + prompt_to_use = [] + profile_addresses = None + if profile and profile.first_name: + profile_addresses = profile.profile_address.all().first() + address_components = [ + profile_addresses.district if profile_addresses and profile_addresses.district else "", + profile_addresses.block if profile_addresses and profile_addresses.block else "", + profile_addresses.state if profile_addresses and profile_addresses.state else "" + ] + address_string = ", ".join(filter(None, address_components)) + + today_date = get_todays_date(company_bot=company_bot) + state_machine_context = "" + state_machine_completion_criteria = "" + if state_machine: + state_machine_context = state_machine.context + if intro_mssg: + context_data = { + "intro_message": intro_mssg, + "user_location": address_string, + "todays_date": today_date + } + template = Template(state_machine_context) + state_machine_context = template.render(context_data) + state_machine_completion_criteria = state_machine.completion_criteria + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + prompt_to_use = [ + { + 'text': system_context + }, + { + 'text': """ + {} + + {} + + Completion Criteria for function calling: + {} + """.format(today_date, state_machine_context, state_machine_completion_criteria) + }, + { + 'text': company_bot.tool_context + } + ] + elif company_bot.provider == LLMProvider.OPENAI: + prompt_to_use = [ + { + 'role': 'system', + 'content': """{} + {} + {} + + Completion Criteria: + {}""".format( + today_date, + system_context, + state_machine_context, + state_machine_completion_criteria + ) + } + ] + + return prompt_to_use diff --git a/chatbot/utils/shiksha_chaupal/checker_utils.py b/chatbot/utils/shiksha_chaupal/checker_utils.py new file mode 100644 index 0000000..98599d3 --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/checker_utils.py @@ -0,0 +1,148 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import LLMProvider +import logging +from jinja2 import Template + + +logger = logging.getLogger('django') + + +def prepare_missing_stage_questions(company_bot, state_machine, messages, extra_data="", profile=None): + print("Preparing for llm call") + system_context = company_bot.context + if state_machine and state_machine.name == "CHECKER": + prompt_to_use = get_guided_prompt( + company_bot=company_bot, state_machine=state_machine, profile=profile + ) + else: + prompt_to_use= get_missing_question_prompt( + company_bot=company_bot, system_context=system_context, state_machine=state_machine, + checker_response=extra_data + ) + response='' + try: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + try: + response = handle_bedrock_model( + system_prompt=prompt_to_use, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + except Exception as e: + logger.error(f"Got Error: %s", e) + print(f"Got Error: {e}") + response = None + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=prompt_to_use, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + is_json_response=False + ) + + print("response_body bedrock: ", response) + if response is None: + response = '' + except Exception as e: + logger.error(f"Error: %s", e) + print(f"Error: {e}") + response = None + + return response + + +def get_guided_prompt(company_bot, state_machine, profile): + prompt_to_use=[] + profile_addresses=None + if profile and profile.first_name: + profile_addresses = profile.profile_address.all().first() + address_components = [ + profile_addresses.district if profile_addresses and profile_addresses.district else "", + profile_addresses.block if profile_addresses and profile_addresses.block else "", + profile_addresses.state if profile_addresses and profile_addresses.state else "" + ] + address_string = ", ".join(filter(None, address_components)) + + state_machine_context = state_machine.context + context_data = { + "user_location": address_string + } + template = Template(state_machine_context) + state_machine_context = template.render(context_data) + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + prompt_to_use = [ + { + 'text': """ + {} + + Completion Criteria for function calling: + {} + """.format(state_machine_context, state_machine.completion_criteria) + }, + # { + # 'text': company_bot.tool_context + # } + ] + elif company_bot.provider == LLMProvider.OPENAI: + prompt_to_use = [ + { + 'role': 'system', + 'content': """ + {} + + Completion Criteria: + {}""".format( + state_machine_context, + state_machine.completion_criteria + ) + } + ] + + return prompt_to_use + + +def get_missing_question_prompt(company_bot, system_context, state_machine, checker_response): + print("Preparing for llm call") + prompt_to_use=[] + state_machine_context = state_machine.context + state_machine_completion_criteria = state_machine.completion_criteria + context_data = { + "missing_questions": checker_response + } + template = Template(state_machine_context) + template_completion_criteria = Template(state_machine_completion_criteria) + state_machine_context = template.render(context_data) + state_machine_completion_criteria = template_completion_criteria.render(context_data) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + prompt_to_use = [ + { + 'text': system_context + }, + { + 'text': """ + {} + + Completion Criteria for function calling: + {} + """.format(state_machine_context, state_machine_completion_criteria) + }, + { + 'text': company_bot.tool_context + } + ] + elif company_bot.provider == LLMProvider.OPENAI: + prompt_to_use = [ + { + 'role': 'system', + 'content': """{} + + {} + + Completion Criteria: + {}""".format( + system_context, + state_machine_context, state_machine_completion_criteria + ) + } + ] + + return prompt_to_use diff --git a/chatbot/utils/shiksha_chaupal/date_utils.py b/chatbot/utils/shiksha_chaupal/date_utils.py new file mode 100644 index 0000000..33a7001 --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/date_utils.py @@ -0,0 +1,113 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from chatbot.utils.chat_utils import get_guided_chat +from chatbot.utils.shiksha_chaupal.base_utils import get_guided_prompt +import logging +from dateutil import parser +from datetime import datetime +import pytz +import json_repair + + +logger = logging.getLogger('django') +INDIA_TZ = pytz.timezone("Asia/Kolkata") + + +def handle_date_prompt(intro_mssg, profile, company_chats, other_info): + bot_question = None + + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/date-validator') + else: + company_bot = CompanyBot.objects.get(route='/date-validator') + + prompt_to_use = get_guided_prompt( + company_bot=company_bot, system_context=company_bot.context + ) + + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats + ) + + response = None + try: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + response = handle_bedrock_model( + system_prompt=prompt_to_use, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot + ) + elif company_bot.provider == LLMProvider.OPENAI: + response = handle_openai_model( + system_prompt=prompt_to_use, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + is_json_response=True + ) + except Exception as e: + logger.error(f"Error in handle_date_prompt: {e}") + response = None + + if not response: + return "I am sorry, I could not understand completely. Could you rephrase this please?" + + date_type, user_date = interpret_date_response(response) + logger.info(f"date_type: %s", date_type) + + try: + last_ai_message = company_chats.filter(receiver__id=1).order_by('-created_at').first() + if last_ai_message: + last_ai_message.translated_message = user_date + last_ai_message.save() + logger.info(f"Updated last AI message with user_date: %s", user_date) + except Exception as e: + logger.error(f"Error updating translated_message: {e}") + + end_context = json_repair.repair_json(company_bot.end_context, return_objects=True) + + if end_context: + bot_question = end_context.get(date_type, None) + + return bot_question + + +def interpret_date_response(date_response): + if date_response and isinstance(date_response, str): + date_response = json_repair.repair_json(date_response, return_objects=True) + + if date_response and isinstance(date_response, dict): + if (isinstance(date_response, dict) and date_response.get("type") and + "value" in date_response): + value = date_response.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + date_response = value + user_date = date_response.get("parsed_date", '') + logger.info(f"user_date: %s", user_date) + try: + parsed_date = parser.parse(user_date, dayfirst=True) + logger.info(f"parsed_date: %s", parsed_date) + today = datetime.now(INDIA_TZ).date() + logger.info(f"today: %s", today) + + normalized_user_date = user_date.lower() + logger.info(f"normalized_user_date date: %s", normalized_user_date) + + if parsed_date.year == today.year and str(today.year) not in normalized_user_date: + return "PHRASE", user_date + if (parsed_date.month == today.month and + str(parsed_date.month) not in normalized_user_date and + parsed_date.strftime('%B').lower() not in normalized_user_date and + parsed_date.strftime('%b').lower() not in normalized_user_date): + return "PHRASE" + + if parsed_date.day == today.day and str(parsed_date.day) not in normalized_user_date: + return "PHRASE", user_date + + if parsed_date.date() > today: + return "FUTURE_DATE", user_date + elif parsed_date.date() == today: + return "PAST_DATE", user_date + else: + return "PAST_DATE", user_date + + except Exception: + return "PHRASE", user_date diff --git a/chatbot/utils/shiksha_chaupal/iterative_challenge_processor.py b/chatbot/utils/shiksha_chaupal/iterative_challenge_processor.py new file mode 100644 index 0000000..670f5fd --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/iterative_challenge_processor.py @@ -0,0 +1,413 @@ +""" +Iterative Challenge Processor Utility + +This utility runs the unique challenges filtering script iteratively until +the filtering threshold is met or maximum iterations are reached. +""" +import json +import os +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +from chatbot.scripts.guest_discussion.post_processing.challenges_script import ( + run_unique_challenge_processing, + DEFAULT_BATCH_SIZE, + DEFAULT_MAX_WORKERS, + CHALLENGE_CATEGORIES +) +from chatbot.utils.S3.s3_service import upload_file_to_s3 + + +# -------------- CONFIG ------------------ +DEFAULT_MAX_ITERATIONS = 10 +DEFAULT_FILTER_THRESHOLD = 10.0 # Stop if less than 10% items were removed +OUTPUT_DIR = 'chatbot/scripts/challenges/iterative_output' + + +class IterativeChallengeProcessor: + """ + Processor that runs unique challenge filtering iteratively until + the output stabilizes. + """ + + def __init__( + self, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + filter_threshold: float = DEFAULT_FILTER_THRESHOLD, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + output_dir: str = OUTPUT_DIR + ): + self.max_iterations = max_iterations + self.filter_threshold = filter_threshold + self.batch_size = batch_size + self.max_workers = max_workers + self.output_dir = output_dir + self.category_counts = {} # Will store iteration 1 category breakdown + + # Ensure output directory exists + os.makedirs(self.output_dir, exist_ok=True) + + def calculate_removal_percentage(self, input_count: int, output_count: int) -> float: + """ + Calculate the percentage of items removed. + """ + if input_count == 0: + return 0.0 + + removed = input_count - output_count + percentage = (removed / input_count) * 100 + return round(percentage, 2) + + def should_continue_filtering(self, input_count: int, output_count: int) -> bool: + """ + Determine if filtering should continue based on removal percentage. + """ + removal_percentage = self.calculate_removal_percentage(input_count, output_count) + + # If removal percentage is greater than or equal to threshold, continue filtering + # If it's less than threshold, we've reached satisfactory uniqueness + return removal_percentage >= self.filter_threshold + + def run_iterative_processing( + self, + input_data: Optional[List] = None, + input_file: Optional[str] = None, + date_from: Optional[str] = None, + date_till: Optional[str] = None + ) -> Dict[str, Any]: + """ + Run iterative challenge processing until threshold is met or max iterations reached. """ + result = { + 'success': False, + 'final_challenges': [], + 'category_counts': {}, + 'iterations_completed': 0, + 'stats': [], + 'output_file': None, + 'message': '' + } + + try: + # Load initial data + current_data = self._load_initial_data(input_data, input_file, date_from, date_till) + + if not current_data: + result['message'] = 'No input data provided or loaded' + return result + + initial_count = len(current_data) + print(f"\n{'='*60}") + print(f"🚀 ITERATIVE CHALLENGE PROCESSOR") + print(f"{'='*60}") + print(f"📊 Initial challenges count: {initial_count}") + print(f"⚙️ Max iterations: {self.max_iterations}") + print(f"⚙️ Filter threshold: {self.filter_threshold}%") + print(f"⚙️ Batch size: {self.batch_size}") + print(f"⚙️ Max workers: {self.max_workers}") + print(f"{'='*60}\n") + + iteration = 0 + + while iteration < self.max_iterations: + iteration += 1 + input_count = len(current_data) + + print(f"\n{'─'*50}") + print(f"🔄 ITERATION {iteration}/{self.max_iterations}") + print(f"{'─'*50}") + print(f" Input count: {input_count}") + + # Skip if too few items + if input_count < 2: + print(f" ⚠️ Too few items to process, stopping.") + break + + # Run the challenge processing + _, combined_result = run_unique_challenge_processing( + input_data=current_data, + batch_size=self.batch_size, + max_workers=self.max_workers, + save_to_file=False + ) + + # Extract challenges and category counts from combined result + output_challenges = combined_result.get('challenges', []) + batch_category_counts = combined_result.get('category_counts', {}) + + # Capture category counts from iteration 1 only (original input distribution) + if iteration == 1: + self.category_counts = batch_category_counts + print(f" 📊 Category counts (from original input):") + for cat_name, cat_count in self.category_counts.items(): + print(f" {cat_name}: {cat_count}") + + output_count = len(output_challenges) + removal_percentage = self.calculate_removal_percentage(input_count, output_count) + + # Record stats + iteration_stats = { + 'iteration': iteration, + 'input_count': input_count, + 'output_count': output_count, + 'removed_count': input_count - output_count, + 'removal_percentage': removal_percentage + } + result['stats'].append(iteration_stats) + + print(f" Output count: {output_count}") + print(f" Removed: {input_count - output_count} ({removal_percentage}%)") + + # Check if we should stop + if not self.should_continue_filtering(input_count, output_count): + print(f"\n ✅ Threshold reached! Removal ({removal_percentage}%) < threshold ({self.filter_threshold}%)") + current_data = output_challenges + break + + # Prepare for next iteration + current_data = output_challenges + print(f" ➡️ Continuing to next iteration...") + + # Save final output + result['final_challenges'] = current_data + result['category_counts'] = self.category_counts + result['iterations_completed'] = iteration + + # Generate output file + output_file = self._save_output(current_data, initial_count, self.category_counts) + + if output_file: + result['success'] = True + result['output_file'] = output_file + else: + result['success'] = False + result['message'] = 'Processing completed but S3 upload failed. Please try again.' + return result + + # Generate summary + total_removed = initial_count - len(current_data) + total_removal_pct = self.calculate_removal_percentage(initial_count, len(current_data)) + + print(f"\n{'='*60}") + print(f"✅ PROCESSING COMPLETE") + print(f"{'='*60}") + print(f"📊 Initial count: {initial_count}") + print(f"📊 Final count: {len(current_data)}") + print(f"📊 Total removed: {total_removed} ({total_removal_pct}%)") + print(f"📊 Iterations: {iteration}") + print(f"📁 Output file: {output_file}") + print(f"{'='*60}\n") + + result['message'] = f'Processing complete. {total_removed} duplicates removed ({total_removal_pct}%) in {iteration} iterations.' + + except Exception as e: + result['message'] = f'Error during processing: {str(e)}' + print(f"\n❌ Error: {str(e)}") + import traceback + traceback.print_exc() + + return result + + def _load_initial_data( + self, + input_data: Optional[List], + input_file: Optional[str], + date_from: Optional[str], + date_till: Optional[str] + ) -> List[Dict[str, Any]]: + """ + Load initial data from provided source. + Returns List[dict] with keys: challenge_text, challenge_count, category. + """ + if input_data: + return self._normalize_challenges(input_data) + + if input_file: + with open(input_file, 'r') as f: + data = json.load(f) + return self._normalize_challenges(data) + + if date_from and date_till: + return self._fetch_challenges_from_db(date_from, date_till) + + return [] + + def _normalize_challenges(self, data: Any) -> List[Dict[str, Any]]: + """ + Normalize challenge data to a list of dicts with keys: + challenge_text, challenge_count, category. + """ + if not isinstance(data, list): + return [] + + challenges = [] + for item in data: + if isinstance(item, str) and item.strip(): + challenges.append({ + 'challenge_text': item.strip(), + 'challenge_count': 1, + 'category': '' + }) + elif isinstance(item, dict): + # Support both old {'challenge': '...'} and new {'challenge_text': '...'} formats + text = item.get('challenge_text') or item.get('challenge') or '' + if isinstance(text, str) and text.strip(): + challenges.append({ + 'challenge_text': text.strip(), + 'challenge_count': item.get('challenge_count', 1), + 'category': item.get('category', '') + }) + + return challenges + + def _fetch_challenges_from_db(self, date_from: str, date_till: str) -> List[Dict[str, Any]]: + """ + Fetch challenges from database based on date range. + Returns List[dict] with keys: challenge_text, challenge_count, category. + """ + from datetime import datetime + from chatbot.models import Story, SessionFlowName + + try: + # Parse dates (DD-MM-YYYY format) + start_date = datetime.strptime(date_from, '%d-%m-%Y') + end_date = datetime.strptime(date_till, '%d-%m-%Y') + + # Make end_date inclusive by setting to end of day + end_date = end_date.replace(hour=23, minute=59, second=59) + + # Query stories in date range with guest-discussion flow + stories = Story.objects.filter( + created_at__gte=start_date, + created_at__lte=end_date + ).values_list('other_params', flat=True) + + challenges = [] + guest_discussion_flow = SessionFlowName.GuestDiscussion.value # 'guest-discussion' + + for other_params in stories: + if other_params and isinstance(other_params, dict): + # Filter by flow + flow = other_params.get('flow') + if flow != guest_discussion_flow: + continue + + # Extract challenges from 'challenges_faced' + challenges_faced = other_params.get('challenges_faced') + + if challenges_faced: + # Handle both list and string formats + if isinstance(challenges_faced, list): + for challenge in challenges_faced: + if isinstance(challenge, str) and challenge.strip(): + challenges.append({ + 'challenge_text': challenge.strip(), + 'challenge_count': 1, + 'category': '' + }) + elif isinstance(challenges_faced, str) and challenges_faced.strip(): + challenges.append({ + 'challenge_text': challenges_faced.strip(), + 'challenge_count': 1, + 'category': '' + }) + + # Handle empty results + if not challenges: + print(f"⚠️ No challenges found in date range {date_from} to {date_till}") + print(f" Stories fetched: {stories.count()}, Flow filter: {guest_discussion_flow}") + return [] + + print(f"✓ Fetched {len(challenges)} challenges from {stories.count()} stories") + print(f" Date range: {date_from} to {date_till}") + return challenges + + except Exception as e: + print(f"❌ Error fetching from database: {e}") + import traceback + traceback.print_exc() + return [] + + def _save_output(self, challenges: List[Dict[str, Any]], initial_count: int, category_counts: Dict[str, int] = None) -> str: + """ + Save the final output to S3 and return the S3 URL. + """ + # Normalize challenges to the enriched format + normalized_challenges = [] + for item in challenges: + if isinstance(item, dict) and item.get('challenge_text'): + normalized_challenges.append({ + 'challenge_text': item['challenge_text'], + 'challenge_count': item.get('challenge_count', 1), + 'category': item.get('category', '') + }) + elif isinstance(item, str): + normalized_challenges.append({ + 'challenge_text': item, + 'challenge_count': 1, + 'category': '' + }) + + output_data = { + 'metadata': { + 'generated_at': datetime.now().isoformat(), + 'initial_count': initial_count, + 'final_count': len(normalized_challenges), + 'removed_count': initial_count - len(normalized_challenges), + 'filter_threshold': self.filter_threshold, + 'max_iterations': self.max_iterations, + 'category_counts': category_counts or {} + }, + 'challenges': normalized_challenges + } + + # Convert to JSON bytes + json_content = json.dumps(output_data, indent=2).encode('utf-8') + + # Upload to S3 + s3_key = upload_file_to_s3( + file_name='unique_challenges.json', + file_content=json_content, + content_type='application/json', + project_id=None, + folder_structure='Mitra/post_processing/' + ) + + if s3_key: + # Construct S3 URL using S3_MEDIA_URL (matches the bucket where files are uploaded) + s3_media_url = os.getenv('S3_MEDIA_URL', '') + s3_url = f"{s3_media_url}{s3_key}" + print(f"✅ File uploaded to S3: {s3_url}") + return s3_url + else: + # S3 upload failed - return error indicator + print("❌ S3 upload failed") + return None + + +def run_iterative_challenge_filtering( + input_data: Optional[List] = None, + input_file: Optional[str] = None, + date_from: Optional[str] = None, + date_till: Optional[str] = None, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + filter_threshold: float = DEFAULT_FILTER_THRESHOLD, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + output_dir: str = OUTPUT_DIR +) -> Dict[str, Any]: + + processor = IterativeChallengeProcessor( + max_iterations=max_iterations, + filter_threshold=filter_threshold, + batch_size=batch_size, + max_workers=max_workers, + output_dir=output_dir + ) + + return processor.run_iterative_processing( + input_data=input_data, + input_file=input_file, + date_from=date_from, + date_till=date_till + ) diff --git a/chatbot/utils/shiksha_chaupal/iterative_solution_processor.py b/chatbot/utils/shiksha_chaupal/iterative_solution_processor.py new file mode 100644 index 0000000..2300c60 --- /dev/null +++ b/chatbot/utils/shiksha_chaupal/iterative_solution_processor.py @@ -0,0 +1,389 @@ +import json +import os +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +from chatbot.scripts.guest_discussion.post_processing.solution_script import ( + run_unique_solution_processing, + DEFAULT_BATCH_SIZE, + DEFAULT_MAX_WORKERS, + SOLUTION_CATEGORIES +) +from chatbot.utils.S3.s3_service import upload_file_to_s3 + + +# -------------- CONFIG ------------------ +DEFAULT_MAX_ITERATIONS = 10 +DEFAULT_FILTER_THRESHOLD = 10.0 +OUTPUT_DIR = 'chatbot/scripts/solutions/iterative_output' + + +class IterativeSolutionProcessor: + def __init__( + self, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + filter_threshold: float = DEFAULT_FILTER_THRESHOLD, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + output_dir: str = OUTPUT_DIR + ): + self.max_iterations = max_iterations + self.filter_threshold = filter_threshold + self.batch_size = batch_size + self.max_workers = max_workers + self.output_dir = output_dir + self.category_counts = {} # Will store iteration 1 category breakdown + + # Ensure output directory exists + os.makedirs(self.output_dir, exist_ok=True) + + def calculate_removal_percentage(self, input_count: int, output_count: int) -> float: + if input_count == 0: + return 0.0 + + removed = input_count - output_count + percentage = (removed / input_count) * 100 + return round(percentage, 2) + + def should_continue_filtering(self, input_count: int, output_count: int) -> bool: + removal_percentage = self.calculate_removal_percentage(input_count, output_count) + + return removal_percentage >= self.filter_threshold + + def run_iterative_processing( + self, + input_data: Optional[List] = None, + input_file: Optional[str] = None, + date_from: Optional[str] = None, + date_till: Optional[str] = None + ) -> Dict[str, Any]: + result = { + 'success': False, + 'final_solutions': [], + 'category_counts': {}, + 'iterations_completed': 0, + 'stats': [], + 'output_file': None, + 'message': '' + } + + try: + # Load initial data + current_data = self._load_initial_data(input_data, input_file, date_from, date_till) + + if not current_data: + result['message'] = 'No input data provided or loaded' + return result + + initial_count = len(current_data) + print(f"\n{'='*60}") + print(f"🚀 ITERATIVE SOLUTION PROCESSOR") + print(f"{'='*60}") + print(f"📊 Initial solutions count: {initial_count}") + print(f"⚙️ Max iterations: {self.max_iterations}") + print(f"⚙️ Filter threshold: {self.filter_threshold}%") + print(f"⚙️ Batch size: {self.batch_size}") + print(f"⚙️ Max workers: {self.max_workers}") + print(f"{'='*60}\n") + + iteration = 0 + + while iteration < self.max_iterations: + iteration += 1 + input_count = len(current_data) + + print(f"\n{'─'*50}") + print(f"🔄 ITERATION {iteration}/{self.max_iterations}") + print(f"{'─'*50}") + print(f" Input count: {input_count}") + + # Skip if too few items + if input_count < 2: + print(f" ⚠️ Too few items to process, stopping.") + break + + # Run the solution processing + _, combined_result = run_unique_solution_processing( + input_data=current_data, + batch_size=self.batch_size, + max_workers=self.max_workers, + save_to_file=False + ) + + # Extract solutions and category counts from combined result + output_solutions = combined_result.get('solutions', []) + batch_category_counts = combined_result.get('category_counts', {}) + + # Capture category counts from iteration 1 only (original input distribution) + if iteration == 1: + self.category_counts = batch_category_counts + print(f" 📊 Category counts (from original input):") + for cat_name, cat_count in self.category_counts.items(): + print(f" {cat_name}: {cat_count}") + + output_count = len(output_solutions) + removal_percentage = self.calculate_removal_percentage(input_count, output_count) + + # Record stats + iteration_stats = { + 'iteration': iteration, + 'input_count': input_count, + 'output_count': output_count, + 'removed_count': input_count - output_count, + 'removal_percentage': removal_percentage + } + result['stats'].append(iteration_stats) + + print(f" Output count: {output_count}") + print(f" Removed: {input_count - output_count} ({removal_percentage}%)") + + # Check if we should stop + if not self.should_continue_filtering(input_count, output_count): + print(f"\n ✅ Threshold reached! Removal ({removal_percentage}%) < threshold ({self.filter_threshold}%)") + current_data = output_solutions + break + + # Prepare for next iteration + current_data = output_solutions + print(f" ➡️ Continuing to next iteration...") + + # Save final output + result['final_solutions'] = current_data + result['category_counts'] = self.category_counts + result['iterations_completed'] = iteration + + # Generate output file + output_file = self._save_output(current_data, initial_count, self.category_counts) + + if output_file: + result['success'] = True + result['output_file'] = output_file + else: + result['success'] = False + result['message'] = 'Processing completed but S3 upload failed. Please try again.' + return result + + # Generate summary + total_removed = initial_count - len(current_data) + total_removal_pct = self.calculate_removal_percentage(initial_count, len(current_data)) + + print(f"\n{'='*60}") + print(f"✅ PROCESSING COMPLETE") + print(f"{'='*60}") + print(f"📊 Initial count: {initial_count}") + print(f"📊 Final count: {len(current_data)}") + print(f"📊 Total removed: {total_removed} ({total_removal_pct}%)") + print(f"📊 Iterations: {iteration}") + print(f"📁 Output file: {output_file}") + print(f"{'='*60}\n") + + result['message'] = f'Processing complete. {total_removed} duplicates removed ({total_removal_pct}%) in {iteration} iterations.' + + except Exception as e: + result['message'] = f'Error during processing: {str(e)}' + print(f"\n❌ Error: {str(e)}") + import traceback + traceback.print_exc() + + return result + + def _load_initial_data( + self, + input_data: Optional[List], + input_file: Optional[str], + date_from: Optional[str], + date_till: Optional[str] + ) -> List[Dict[str, Any]]: + if input_data: + return self._normalize_solutions(input_data) + + if input_file: + with open(input_file, 'r') as f: + data = json.load(f) + return self._normalize_solutions(data) + + if date_from and date_till: + return self._fetch_solutions_from_db(date_from, date_till) + + return [] + + def _normalize_solutions(self, data: Any) -> List[Dict[str, Any]]: + """ + Normalize solution data to a list of dicts with keys: + solution_text, solution_count, category. + """ + if not isinstance(data, list): + return [] + + solutions = [] + for item in data: + if isinstance(item, str) and item.strip(): + solutions.append({ + 'solution_text': item.strip(), + 'solution_count': 1, + 'category': '' + }) + elif isinstance(item, dict): + # Support both old {'solution': '...'} and new {'solution_text': '...'} formats + text = item.get('solution_text') or item.get('solution') or '' + if isinstance(text, str) and text.strip(): + solutions.append({ + 'solution_text': text.strip(), + 'solution_count': item.get('solution_count', 1), + 'category': item.get('category', '') + }) + + return solutions + + def _fetch_solutions_from_db(self, date_from: str, date_till: str) -> List[Dict[str, Any]]: + """ + Fetch solutions from database based on date range. + """ + from datetime import datetime + from chatbot.models import Story, SessionFlowName + + try: + # Parse dates (DD-MM-YYYY format) + start_date = datetime.strptime(date_from, '%d-%m-%Y') + end_date = datetime.strptime(date_till, '%d-%m-%Y') + + # Make end_date inclusive by setting to end of day + end_date = end_date.replace(hour=23, minute=59, second=59) + + # Query stories in date range with guest-discussion flow + stories = Story.objects.filter( + created_at__gte=start_date, + created_at__lte=end_date + ).values_list('other_params', flat=True) + + solutions = [] + guest_discussion_flow = SessionFlowName.GuestDiscussion.value # 'guest-discussion' + + for other_params in stories: + if other_params and isinstance(other_params, dict): + # Filter by flow + flow = other_params.get('flow') + if flow != guest_discussion_flow: + continue + + # Extract solutions from 'solutions_discussed' + solutions_discussed = other_params.get('solutions_discussed') + + if solutions_discussed: + # Handle both list and string formats + if isinstance(solutions_discussed, list): + for solution in solutions_discussed: + if isinstance(solution, str) and solution.strip(): + solutions.append({ + 'solution_text': solution.strip(), + 'solution_count': 1, + 'category': '' + }) + elif isinstance(solutions_discussed, str) and solutions_discussed.strip(): + solutions.append({ + 'solution_text': solutions_discussed.strip(), + 'solution_count': 1, + 'category': '' + }) + + # Handle empty results + if not solutions: + print(f"⚠️ No solutions found in date range {date_from} to {date_till}") + print(f" Stories fetched: {stories.count()}, Flow filter: {guest_discussion_flow}") + return [] + + print(f"✓ Fetched {len(solutions)} solutions from {stories.count()} stories") + print(f" Date range: {date_from} to {date_till}") + return solutions + + except Exception as e: + print(f"❌ Error fetching from database: {e}") + import traceback + traceback.print_exc() + return [] + + def _save_output(self, solutions: List[Dict[str, Any]], initial_count: int, category_counts: Dict[str, int] = None) -> str: + """ + Save the final output to S3 and return the S3 URL. + """ + # Normalize solutions to the enriched format + normalized_solutions = [] + for item in solutions: + if isinstance(item, dict) and item.get('solution_text'): + normalized_solutions.append({ + 'solution_text': item['solution_text'], + 'solution_count': item.get('solution_count', 1), + 'category': item.get('category', '') + }) + elif isinstance(item, str): + normalized_solutions.append({ + 'solution_text': item, + 'solution_count': 1, + 'category': '' + }) + + output_data = { + 'metadata': { + 'generated_at': datetime.now().isoformat(), + 'initial_count': initial_count, + 'final_count': len(normalized_solutions), + 'removed_count': initial_count - len(normalized_solutions), + 'filter_threshold': self.filter_threshold, + 'max_iterations': self.max_iterations, + 'category_counts': category_counts or {} + }, + 'solutions': normalized_solutions + } + + # Convert to JSON bytes + json_content = json.dumps(output_data, indent=2).encode('utf-8') + + # Upload to S3 + s3_key = upload_file_to_s3( + file_name='unique_solutions.json', + file_content=json_content, + content_type='application/json', + project_id=None, + folder_structure='Mitra/post_processing/' + ) + + if s3_key: + # Construct S3 URL using S3_MEDIA_URL (matches the bucket where files are uploaded) + s3_media_url = os.getenv('S3_MEDIA_URL', '') + s3_url = f"{s3_media_url}{s3_key}" + print(f"✅ File uploaded to S3: {s3_url}") + return s3_url + else: + # S3 upload failed - return error indicator + print("❌ S3 upload failed") + return None + + +def run_iterative_solution_filtering( + input_data: Optional[List[str]] = None, + input_file: Optional[str] = None, + date_from: Optional[str] = None, + date_till: Optional[str] = None, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + filter_threshold: float = DEFAULT_FILTER_THRESHOLD, + batch_size: int = DEFAULT_BATCH_SIZE, + max_workers: int = DEFAULT_MAX_WORKERS, + output_dir: str = OUTPUT_DIR +) -> Dict[str, Any]: + """ + Main function to run iterative solution filtering. + """ + processor = IterativeSolutionProcessor( + max_iterations=max_iterations, + filter_threshold=filter_threshold, + batch_size=batch_size, + max_workers=max_workers, + output_dir=output_dir + ) + + return processor.run_iterative_processing( + input_data=input_data, + input_file=input_file, + date_from=date_from, + date_till=date_till + ) diff --git a/chatbot/utils/shikshalokam_mitra_utils.py b/chatbot/utils/shikshalokam_mitra_utils.py new file mode 100644 index 0000000..4d441fa --- /dev/null +++ b/chatbot/utils/shikshalokam_mitra_utils.py @@ -0,0 +1,342 @@ +import os +import re +import requests +from datetime import timedelta, timezone +from pydantic_core._pydantic_core import ValidationError +from django.utils.timezone import now +from chatbot.models import Profile, CompanyChat, ChatSession +import json + +from chatbot.utils.story_llama_utils import generate_random_hex +from shikshalokam.models import Project, Task +import ast + + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def create_project_utils( + access_token, + user_problem_statement, + project_title, + project_duration_weeks, + user_action_steps, + project_objective, + original_project=None, + chunks=None, + session=None, + status="completed", +): + url = f"https://{base_url}/userProjects/add" + + headers = { + "X-auth-token": access_token, + } + numeric_duration = extract_numeric(project_duration_weeks) + print("numeric_duration: ", numeric_duration) + start_date = now() + start_date = start_date.astimezone(tz=timezone.utc) + + end_date = (start_date + timedelta(weeks=numeric_duration)) + start_date = start_date.isoformat() + end_date = end_date.isoformat() + conversation = [] + if session: + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + + conversation = get_stored_conversation(company_chats=company_chats) + + if original_project: + chunks = original_project.project_source + if not chunks: + chunks = {} + else: + chunks = chunks.strip('{}') + chunks = ast.literal_eval('{' + chunks + '}') + print(type(chunks)) + chunks["projectId"]= original_project.project_id + if original_project.template_id: + chunks["projectTemplateId"]= original_project.template_id + + if not chunks: + chunks = {"relevant_texts": []} + + print("final chunks: ", chunks) + print("final chunks type: ", type(chunks)) + request_body = { + "program": { + "name": user_problem_statement, + "startDate": start_date, + "source": { + "model": "llama3.1", + "provider": "Bedrock" + } + }, + "projects": [ + { + "conversation": conversation, + "duration": f"{project_duration_weeks} week", + "endDate": end_date, + "source": chunks, + "startDate": start_date, + "status": status, + "tasks": [ + { + "isDeletable": True, + "name": step, + "source": { + "model": "llama3.1", + "provider": "Bedrock" + }, + } for step in user_action_steps + ], + "title": project_title, + "description": project_objective + } + ] + } + + # print("req body: ", request_body) + + try: + response = requests.post(url, headers=headers, json=request_body) + print("response: ", response.json()) + response.raise_for_status() + json_response = response.json() + print("json_response: ", json_response) + + if not json_response or "result" not in json_response: + raise ValidationError("Invalid response from the API") + + program_id = json_response["result"].get("programId") + project_id = json_response["result"].get('projects')[0].get("_id") + + return { + "original_response": json_response, + "programId": program_id, + "projectId": project_id, + "chunks": chunks + } + + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the API call: {e}") + return None + except ValueError as e: + print(f"Validation error: {e}") + return None + + +def extract_numeric(value): + if value: + match = re.search(r'\d+', str(value)) + return int(match.group()) if match else None + return None + + +def create_mitra_project_utils( + chunks=None, actual_problem_statement=None, project_title=None, project_duration=None, + project_objective=None, project_id=None, program_id=None, profile=None, + language=None, session=None, user_action_steps = [], description=None +): + try: + + chat_session = ChatSession.objects.filter(session=session).first() + if chat_session and chat_session.project_id: + project_id = chat_session.project_id + + if isinstance(user_action_steps, str): + user_action_steps = json.loads(user_action_steps) + + if not isinstance(user_action_steps, list): + user_action_steps = [] + + if not project_id: + project_id = generate_random_hex() + print(f"Generated new project_id: {project_id}") + + print("project_id: ", project_id) + print("="*50) + default_values = { + 'author': profile, + 'expected_duration': project_duration, + 'expected_title': project_title, + 'expected_problem_statement': actual_problem_statement, + 'expected_objective': project_objective, + 'program_id': program_id, + 'project_source': chunks, + 'program_source':{ + "model": "llama3.3", + "provider": "Bedrock" + }, + 'project_language': language, + 'description': description + } + for k,v in list(default_values.items()): + if v is None: + del default_values[k] + project, created = Project.objects.update_or_create( + project_id=project_id, + defaults=default_values + ) + + for action in user_action_steps: + Task.objects.create( + project=project, + task_name=action, + source={ + "model": "llama3.3", + "provider": "Bedrock" + } + ) + + if chat_session and not chat_session.project_id: + chat_session.project_id = project_id + chat_session.save(update_fields=["project_id"]) + + return { + "status": "success", + "message": "Project and Task created successfully", + "id": project.id, + "project_id": project.project_id, + } + + except Profile.DoesNotExist: + return {"status": "error", "message": "Profile not found"} + except Exception as e: + return {"status": "error", "message": f"An error occurred: {str(e)}"} + + +def import_project_from_library_utils(access_token, program_name, project_template_id, program_id): + url = f"https://{base_url}/userProjects/importFromLibrary/{project_template_id}" + + headers = { + "X-auth-token": access_token, + } + + request_body = { + "programName": program_name, + "programId": program_id + } + + print("req body: ", request_body) + + try: + response = requests.post(url, headers=headers, json=request_body) + response.raise_for_status() + json_response = response.json() + print("json_response: ", json_response) + + if not json_response or "result" not in json_response: + raise ValidationError("Invalid response from the API") + + return json_response + + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the API call: {e}") + return None + except ValueError as e: + print(f"Validation error: {e}") + return None + + +def get_conversation(company_chats, ai_user): + conversation = [] + for chat in company_chats: + user_message = chat.message + if chat.receiver == ai_user: + if chat.translated_message is not None and chat.translated_message != '': + user_message = chat.translated_message + if conversation and len(conversation) > 0: + conversation[-1]["userMessage"] = user_message + + else: + conversation.append({ + "botResponse": user_message, + "timestamp": chat.created_at.isoformat(), + "userMessage": "" + }) + print("\n\nconversation: ", conversation) + + return conversation + + +def get_stored_conversation(company_chats): + ai_user = Profile.objects.values('id').get(id=1) + conversation=[] + for chat in company_chats: + chat_receiver = None + chat_message = None + chat_translated_message = None + chat_created_at = None + + # variable instialisation + if isinstance(chat, CompanyChat): + chat_receiver = getattr(chat.receiver, 'id', None) + chat_message = getattr(chat, 'message', None) + chat_translated_message = getattr(chat, 'translated_message', None) + chat_created_at = getattr(chat, 'created_at', None) + + elif isinstance(chat, dict): + chat_receiver = chat.get("receiver") + chat_message = chat.get("message") + chat_translated_message = chat.get("translated_message") + chat_created_at = chat.get("created_at") + + if chat_receiver == ai_user.get("id"): + user_message = chat_message + if chat_translated_message is not None and chat_translated_message != '': + user_message = chat_translated_message + conversation.append({ + 'user': user_message, + 'timestamp': chat_created_at.strftime('%Y-%m-%d %H:%M:%S'), + }) + else: + conversation.append({ + 'bot': chat_message, + 'timestamp': chat_created_at.strftime('%Y-%m-%d %H:%M:%S'), + }) + + return conversation + +def get_stored_chathistory(company_chats): + ai_user = Profile.objects.values("id").get(id=1) + chat_history=[] + for chat in company_chats: + chat_receiver = None + chat_message = None + chat_translated_message = None + chat_created_at = None + chat_status = None + + # variable instialisation + if isinstance(chat, CompanyChat): + chat_receiver = getattr(chat.receiver, 'id', None) + chat_message = getattr(chat, 'message', None) + chat_translated_message = getattr(chat, 'translated_message', None) + chat_created_at = getattr(chat, 'created_at', None) + chat_status = getattr(chat, 'status', None) + + elif isinstance(chat, dict): + chat_receiver = chat.get("receiver") + chat_message = chat.get("message") + chat_translated_message = chat.get("translated_message") + chat_created_at = chat.get("created_at") + chat_status = chat.get("status") + + if chat_receiver == ai_user.get("id"): + user_message = chat_message + if chat_translated_message is not None and chat_translated_message != '': + user_message = chat_translated_message + chat_history.append({ + 'user': user_message, + 'timestamp': chat_created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'event': chat_status + }) + else: + chat_history.append({ + 'bot': chat_message, + 'timestamp': chat_created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'event': chat_status + }) + + return chat_history diff --git a/chatbot/utils/shikshalokam_story_utils.py b/chatbot/utils/shikshalokam_story_utils.py new file mode 100644 index 0000000..18cc381 --- /dev/null +++ b/chatbot/utils/shikshalokam_story_utils.py @@ -0,0 +1,612 @@ +from chatbot.models import StoryMedia, MediaTypeChoices, CompanyChat, Profile, Story, ChatSession, SessionFlowName, \ + CompanyBot, Flow, Voice, StoryLanguageChoices +from chatbot.models.company_models import PDFTemplates +from chatbot.models.enums import UserTypeChoices, VoiceType +from chatbot.models.story_models import StoryTranslation +from chatbot.models.story_vernacular_model import StoryVernacular +from chatbot.pdf.listening_activity.la_report import get_common_report_html +from chatbot.pdf.shiksha_chaupal.mom_report import get_mom_report_html +from chatbot.pdf.story_first_page import get_first_page_html +from chatbot.pdf.story_images_page import get_story_images_page_html +from chatbot.pdf.story_secondpage import get_story_secondpage_html +from chatbot.pdf.story_thirdpage import get_thirdpage_html +from chatbot.serializer.story_serializer import StoryCreateSerializer +from chatbot.utils.elevate.project_detail import fetch_existing_project_attachments +from chatbot.utils.gotenberg_utils import generate_pdf_with_gotenberg +from chatbot.utils.media_utils import upload_to_cloud +from chatbot.utils.shikshalokam_mitra_utils import get_stored_conversation, get_stored_chathistory +from django.core.files.base import ContentFile +from jinja2 import Template +from shikshalokam.models import Project, Task +from shikshalokam.models.project_vernacular_model import ProjectVernacular +from shikshalokam.serializer import ProjectSerializer +import json +import os +import re +import requests +import traceback +import logging + +logger = logging.getLogger("django") + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def save_shikshalokam_story( + story, problem_statement, chat_history, access_token, project_id, session, + profile, conversation, flow +): + try: + html_content = get_story_html(story=story, profile=profile, flow=flow) + + pdf_generated = generate_pdf_with_gotenberg(html_content) + pdf_file_name = story.title + if not pdf_file_name or pdf_file_name == '': + pdf_file_name = 'Improvement_story' + pdf_file_name = f"{pdf_file_name}.pdf" + pdf_content = ContentFile(pdf_generated, name=pdf_file_name) + print("pdf_content: ", pdf_content) + print("pdf_content type: ", type(pdf_content)) + # StoryMedia.objects.create( + # name=pdf_file_name, + # file=pdf_content, + # story=story, + # include_in_story=False, + # media_type=MediaTypeChoices.PDF + # ) + + story_media, created = StoryMedia.objects.update_or_create( + story=story, + media_type=MediaTypeChoices.PDF, + defaults={ + "name": pdf_file_name, + "file": pdf_content, + "include_in_story": False + } + ) + + if created: + print("New PDF created") + else: + print("Existing PDF updated") + + if access_token in [None, "", "null"] or not session or not project_id or flow != SessionFlowName.Reflection: + print("Not calling shikshalokam api as access_tokne or session or project_id is missing") + return + upload_response_json = upload_to_cloud(session_value=session, access_token=access_token, story=story) + attachments = upload_response_json.get('attachments') + print("attachments: ", attachments) + + pdf_information = upload_response_json.get('pdfInformation') + print("pdf_information: ", pdf_information) + + + request_body = { + "story": { + "title": story.title, + "problemStatement": problem_statement, + "objective": story.objective, + "timeline": "", + "actionSteps": story.action_steps or [], + "resources": [], + "impact": story.impact, + "summary": story.content, + "authorName": story.author.first_name if story.author else "", + "location": story.location or "", + "conversation": conversation, + "chatHistory": chat_history, + "attachments": attachments, + "pdfInformation": pdf_information, + } + } + print("request_body: ", request_body) + print("type: ", type(request_body)) + print("type: ", type(request_body.get("story"))) + + url = f"https://{base_url}/userProjects/addStory/{project_id}" + print("Using url: ", url) + + headers = { + "X-auth-token": access_token, + } + + response = requests.put(url, headers=headers, json=request_body) + print("Res:", response) + print("response: ", response.json()) + response.raise_for_status() + + print(f"Story successfully saved to Shikshalokam: {response.status_code}") + except Exception as e: + traceback.print_exc() + print(f"Failed to save story to Shikshalokam: {str(e)}") + + +def save_project_story( story, problem_statement, chat_history, access_token, project_id, session, profile, conversation, flow): + try: + session_data = ChatSession.objects.get(session=session) + html_content = get_html_from_template(story=story, profile=profile, flow=flow, auth=access_token is not None, language=session_data.language) + + pdf_generated = generate_pdf_with_gotenberg(html_content) + pdf_file_name = story.title + if not pdf_file_name or pdf_file_name == '': + pdf_file_name = 'Improvement_story' + pdf_file_name = f"{pdf_file_name}.pdf" + pdf_content = ContentFile(pdf_generated, name=pdf_file_name) + print("pdf_content: ", pdf_content) + print("pdf_content type: ", type(pdf_content)) + + story_media, created = StoryMedia.objects.update_or_create( + story=story, + media_type=MediaTypeChoices.PDF, + defaults={ + "name": pdf_file_name, + "file": pdf_content, + "include_in_story": False + } + ) + + if created: + print("New PDF created") + else: + print("Existing PDF updated") + + if access_token in [None, "", "null"] or not session or not project_id or flow != SessionFlowName.Reflection: + print("Not calling shikshalokam api as access_tokne or session or project_id is missing") + return + upload_response_json = upload_to_cloud(session_value=session, access_token=access_token, story=story) + attachments = upload_response_json.get('attachments') + print("attachments: ", attachments) + + pdf_information = upload_response_json.get('pdfInformation') + print("pdf_information: ", pdf_information) + + + request_body = { + "story": { + "title": story.title, + "problemStatement": problem_statement, + "objective": story.objective, + "timeline": "", + "actionSteps": story.action_steps or [], + "resources": [], + "impact": story.impact, + "summary": story.content, + "authorName": story.author.first_name if story.author else "", + "location": story.location or "", + "conversation": conversation, + "chatHistory": chat_history, + "attachments": attachments, + "pdfInformation": pdf_information, + } + } + print("request_body: ", request_body) + print("type: ", type(request_body)) + print("type: ", type(request_body.get("story"))) + + url = f"https://{base_url}/userProjects/addStory/{project_id}" + print("Using url: ", url) + + headers = { + "X-auth-token": access_token, + } + + response = requests.put(url, headers=headers, json=request_body) + print("Res:", response) + print("response: ", response.json()) + response.raise_for_status() + + print(f"Story successfully saved to Shikshalokam: {response.status_code}") + except Exception as e: + traceback.print_exc() + print(f"Failed to save story to Shikshalokam: {str(e)}") + raise e + + +def get_story_html(story, profile, flow): + project = Project.objects.filter(story=story).first() + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.SsoFlow, SessionFlowName.GuestMiStory, + SessionFlowName.Reflection, SessionFlowName.YLC]: + css_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../pdf/story_pdf.css")) + elif flow in [SessionFlowName.GuestDiscussion, SessionFlowName.LoginDiscussion]: + css_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../pdf/shiksha_chaupal/mom_report_pdf.css")) + else: + css_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../pdf/listening_activity/la_report_pdf.css")) + if profile: + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.SsoFlow, SessionFlowName.Reflection]: + company_bot = CompanyBot.objects.get(route='/story') + elif flow in [SessionFlowName.GuestMiStory]: + company_bot = CompanyBot.objects.get(route='/guest-story') + elif flow in [SessionFlowName.GuestDiscussion, SessionFlowName.LoginDiscussion]: + company_bot = CompanyBot.objects.get(company=profile.company, route='/chaupal-story') + else: + company_bot = CompanyBot.objects.get(company=profile.company, route=f'/{flow}-story') + else: + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.SsoFlow, SessionFlowName.Reflection]: + company_bot = CompanyBot.objects.get(route='/story') + elif flow in [SessionFlowName.GuestMiStory]: + company_bot = CompanyBot.objects.get(route='/guest-story') + elif flow in [SessionFlowName.GuestDiscussion, SessionFlowName.LoginDiscussion]: + company_bot = CompanyBot.objects.get(route='/chaupal-story') + else: + company_bot = CompanyBot.objects.get(company=profile.company, route=f'/{flow}-story') + + translation_languages = list(story.translations.values_list('language', flat=True)) + chat_session = ChatSession.objects.filter(session=story.session).first() + + language_used = ( + chat_session.language or + translation_languages[0] if translation_languages else + (project.project_language if project else None) or + story.language or + 'en' + ) + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language_used + ).first() + + story_vernacular = StoryVernacular.objects.filter( + company_bot=company_bot, language=language_used + ).first() + if story_vernacular: + logger.info(f"story_vernacular found: {story_vernacular.id} & {story_vernacular.company_bot} & {story_vernacular.language}") + if language_used == 'en': + object_to_pass = story + if project: + project_serializer = ProjectSerializer(project) + project_to_pass = project_serializer.data + else: + project_to_pass = None + else: + try: + story_translation = story.translations.get(language=language_used) + object_to_pass = story_translation + except StoryTranslation.DoesNotExist: + logger.info(f"Translation for language '{language_used}' not found, using English story") + object_to_pass = story + if project: + try: + project_vernacular = project.project_vernacular.get(language=language_used) + project_details = json.loads(project_vernacular.details) + project_to_pass = project_details.get('project', {}) + except ProjectVernacular.DoesNotExist: + print(f"Project vernacular for language '{language_used}' not found, using English project") + project_serializer = ProjectSerializer(project) + project_to_pass = project_serializer.data + except (json.JSONDecodeError, KeyError) as e: + print(f"Error parsing project vernacular details: {e}, using English project") + project_serializer = ProjectSerializer(project) + project_to_pass = project_serializer.data + else: + print("No project found for story, using None for project_to_pass") + project_to_pass = None + + if project_to_pass: + pdf_file_name = project_to_pass.get('expected_title') or project_to_pass.get('actual_title') or "Improvement_story" + else: + pdf_file_name = object_to_pass.title or "Improvement_story" + + print("Using pdf name: ", pdf_file_name) + with open(css_path, 'r') as css_file: + inline_css = css_file.read() + html_content = f""" + + + + + {pdf_file_name} + + + + + + + + """ + + print("Generating for FLOW: ", flow) + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.SsoFlow, SessionFlowName.GuestMiStory, + SessionFlowName.Reflection, SessionFlowName.YLC]: + html_content += get_first_page_html( + profile=profile, project=project_to_pass, voice_provider=voice_provider, story=object_to_pass, + story_vernacular=story_vernacular, flow=flow + ) + html_content += get_story_secondpage_html( + story=object_to_pass, project=project_to_pass, story_vernacular=story_vernacular + ) + html_content += get_story_images_page_html(story=story, story_vernacular=story_vernacular) + html_content += get_thirdpage_html( + story=object_to_pass, profile=profile, project=project_to_pass, voice_provider=voice_provider, + story_vernacular=story_vernacular, flow=flow + ) + elif flow in [SessionFlowName.GuestDiscussion, SessionFlowName.LoginDiscussion]: + html_content += get_mom_report_html( + story=object_to_pass, story_vernacular=story_vernacular, profile=profile, + voice_provider=voice_provider + ) + else: + html_content += get_common_report_html( + story=object_to_pass, profile=profile, story_vernacular=story_vernacular + ) + + html_content += """ + + + + """ + return html_content + + +def get_html_from_template(story, profile, flow, auth=False, language=None): + project = Project.objects.filter(story=story).first() + flow_obj = Flow.objects.get(flow_route=flow) + + language_used = language + + if language_used is None: + chat_session = ChatSession.objects.filter(session=story.session).first() + translation_languages = list(story.translations.values_list('language', flat=True)) + language_used = ( + chat_session.language or + translation_languages[0] if translation_languages else + (project.project_language if project else None) or + story.language or + 'en' + ) + + story_serialized = StoryCreateSerializer(story) + project_serialized = ProjectSerializer(project) + profile_serialized = profile + + pdf_template: PDFTemplates | None = None + if auth: + pdf_template = PDFTemplates.objects.filter( + flow=flow_obj, + user_type__in=[UserTypeChoices.AUTH, UserTypeChoices.ALL] + ).first() + else: + pdf_template = PDFTemplates.objects.filter(flow=flow_obj, + user_type__in=[UserTypeChoices.GUEST, UserTypeChoices.ALL] + ).first() + + if pdf_template is None: + return "" + + jinja_template = pdf_template.template + constants = pdf_template.constants_json + + render_params = { + "constants": constants.get(language_used, {}), + "story": story_serialized.data, + "project": project_serialized.data, + "profile": profile_serialized + } + + if language_used != StoryLanguageChoices.ENGLISH: + translated_story = StoryTranslation.objects.select_related("story").get(story__session=story.session, language=language) + render_params.get("story", {})["title"] = translated_story.title + render_params.get("story", {})["content"] = translated_story.content + render_params.get("story", {})["location"] = translated_story.location + render_params.get("story", {})["other_params"] = translated_story.other_params + + template = Template(jinja_template) + html_content = template.render(**render_params) + return html_content + +def update_story_pdf(access_token, session, flow, is_edit_story=False): + + try: + chatsession = ChatSession.objects.values("language").get(session=session) + + story = Story.objects.get(session=session) + translated_story = None + if chatsession.get("language", StoryLanguageChoices.ENGLISH) != StoryLanguageChoices.ENGLISH: + translated_story = StoryTranslation.objects.select_related("story").get(story__session=session, language=chatsession.get("language", StoryLanguageChoices.ENGLISH)) + + if translated_story is not None: + story.title = translated_story.title + story.content = translated_story.content + story.location = translated_story.location + story.other_params = translated_story.other_params + + if story and story.content and story.formatted_content: + update_story_content(story) + profile = story.author + print("profile: ", profile) + print("story: ", story.title) + print("story format: ", story.formatted_content) + language = chatsession.get("language", StoryLanguageChoices.ENGLISH) + flow_obj = Flow.objects.filter(flow_route=flow).first() + has_pdf_template = flow_obj and PDFTemplates.objects.filter(flow=flow_obj).exists() + if has_pdf_template: + html_content = get_html_from_template( + story=story, profile=profile, flow=flow, + auth=(profile is not None), language=language + ) + else: + html_content = get_story_html(story=story, profile=profile, flow=flow) + + pdf_generated = generate_pdf_with_gotenberg(html_content) + # print("pdf_generated: ", pdf_generated) + pdf_file_name = story.title + if not pdf_file_name or pdf_file_name == '': + pdf_file_name = 'Improvement_story' + pdf_file_name = f"{pdf_file_name}.pdf" + print("pdf_file_name: ", pdf_file_name) + pdf_content = ContentFile(pdf_generated, name=pdf_file_name) + print("pdf_content: ", pdf_content) + print("pdf_content type: ", type(pdf_content)) + + story_media = StoryMedia.objects.filter(story=story, media_type=MediaTypeChoices.PDF).first() + + story_media.name = pdf_file_name + story_media.file.save(pdf_file_name, pdf_content) + story_media.include_in_story = False + story_media.save() + logger.info("StoryMedia updated and saved successfully.") + logger.info(f"Updated name: {story_media.name}") + logger.info(f"Updated file path: {story_media.file}") + logger.info(f"Include in story: {story_media.include_in_story}") + logger.info(f"Public url: {story_media.get_public_url()}") + chat_session = ChatSession.objects.get(session=session) + project_id = chat_session.project_id + + if (access_token in [None, "", "null"] or not session or not project_id or + flow not in[SessionFlowName.Reflection, SessionFlowName.GuestMiStory]): + print("Not calling shikshalokam api as access_tokne or session or project_id is missing") + return + + upload_response_json = upload_to_cloud( + session_value=session, access_token=access_token, story=story + ) + + print("upload_response_json: ", upload_response_json) + story_media_objects = StoryMedia.objects.filter( + story=story, include_in_story=True + ).exclude(media_type=MediaTypeChoices.PDF) + attachments=[] + if is_edit_story: + existing_attachments = fetch_existing_project_attachments(project_id, access_token) + print("existing_attachments: ", existing_attachments) + if existing_attachments: + attachments.extend(existing_attachments) + + attachments.extend([ + { + "name": media.name, + "sourcePath": media.source_path, + "type": media.media_type, + "page": "story" + + } + for media in story_media_objects + ]) + print("attachments: ", attachments) + + pdf_information = upload_response_json.get('pdfInformation') + print("pdf_information: ", pdf_information) + + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + ai_user = Profile.objects.get(id=1) + + if company_chats and company_chats[0].receiver != ai_user: + company_chats.pop(0) + conversation = get_stored_conversation(company_chats=company_chats) + chat_history = get_stored_chathistory(company_chats=company_chats) + + + tasks_payload = [] + + task_id_from_session = None + if chat_session.other_params: + task_id_from_session = chat_session.other_params.get('task_id') + + if task_id_from_session: + task_obj = Task.objects.filter(task_id=task_id_from_session).first() + + if task_obj: + tasks_payload.append({ + "_id": task_obj.task_id, + "status": task_obj.task_status, + "taskName": task_obj.task_name + }) + else: + tasks_payload.append({ + "_id": task_id_from_session, + "status": "completed" + }) + + else: + project = Project.objects.filter(project_id=project_id).first() + if project: + project_tasks = Task.objects.filter(project=project) + + for task in project_tasks: + tasks_payload.append({ + "_id": task.task_id, + "status": task.task_status.lower() if task.task_status else None, + "taskName": task.task_name + }) + + request_body = { + "story": { + "title": story.title, + "objective": story.objective, + "timeline": "", + "actionSteps": story.action_steps or [], + "resources": [], + "impact": story.impact, + "summary": story.content, + "authorName": story.author.first_name if story.author else "", + "location": story.location or "", + "conversation": conversation, + "chatHistory": chat_history, + "attachments": attachments, + "pdfInformation": pdf_information, + }, + "tasks": tasks_payload + } + + headers = { + "X-auth-token": access_token, + } + print("Req body: ", request_body) + if flow in [SessionFlowName.GuestMiStory]: + url = f"https://{base_url}/userProjects/update/{project_id}" + response = requests.post(url, headers=headers, json=request_body) + else: + url = f"https://{base_url}/userProjects/addStory/{project_id}" + response = requests.put(url, headers=headers, json=request_body) + + print("Response: ", response.text) + response.raise_for_status() + + print(f"Story successfully updated to Shikshalokam: {response.status_code}") + + except requests.exceptions.RequestException as e: + print("Failed to save story to Shikshalokam: %s", e) + raise + except Exception as e: + print("An unexpected error occurred: %s", e) + traceback.print_exc() + raise + + +def update_story_content(story): + try: + formatted_data = json.loads(story.formatted_content) + except (json.JSONDecodeError, TypeError): + print("Invalid or missing formatted_content") + return + + accumulated_text = "" + for block in formatted_data: + if block.get("type") == "paragraph" and "data" in block and "text" in block["data"]: + # accumulated_text += block["data"]["text"] + "\n" + plain_text = re.sub(r'<[^>]+>', '', block["data"]["text"]) + accumulated_text += plain_text + "\n" + + print("\nold content: ", story.content) + print("\naccumulated_text: ", accumulated_text) + story.content = accumulated_text.strip() + story.save() diff --git a/chatbot/utils/sql_utils.py b/chatbot/utils/sql_utils.py new file mode 100644 index 0000000..6468a96 --- /dev/null +++ b/chatbot/utils/sql_utils.py @@ -0,0 +1,70 @@ +import re +from django.db import connection +import json_repair +from chatbot.models import CompanyBotDynamicContextType +from datetime import datetime, date, timedelta +import logging + + +logger = logging.getLogger('django') + + +def run_sql_from_string(string): + matches = re.findall(r'\{\{\s*(.*?)\s*\}\}', string) + + if not matches: + return string + + replacements = [] + + for sql in matches: + with connection.cursor() as cursor: + cursor.execute(sql) + + fetched_results = cursor.fetchall() + columns = [col[0] for col in cursor.description] + result_str = str([dict(zip(columns, row)) for row in fetched_results]) + + replacements.append(result_str) + + for i, sql in enumerate(matches): + string = string.replace(f"{{{{ {sql} }}}}", replacements[i]) + + logger.info(f"Resultant string: %s", string) + return string + + +def get_todays_date(company_bot): + today_date = "" + try: + if company_bot and company_bot.dynamic_context_type == CompanyBotDynamicContextType.SQL_QUERY: + dynamic_context = company_bot.dynamic_context + if isinstance(dynamic_context, str): + dynamic_context = json_repair.repair_json(dynamic_context, return_objects=True) + sql_result = run_sql_from_string(dynamic_context.get('date')) + logger.info(f"Resultant string: %s", sql_result) + + parsed_date = None + if isinstance(sql_result, str): + match = re.search(r"datetime\.date\((\d+), (\d+), (\d+)\)", sql_result) + if match: + year, month, day = map(int, match.groups()) + parsed_date = date(year, month, day) + + elif isinstance(sql_result, list) and len(sql_result) > 0: + val = sql_result[0].get('current_date') + if isinstance(val, date): + parsed_date = val + elif isinstance(val, str): + parsed_date = datetime.strptime(val, "%d %B %Y").date() + logger.info(f"parsed_date: %s", parsed_date) + if parsed_date: + today_weekday = parsed_date.strftime('%A') + + today_date = f"{parsed_date.strftime('%d %B %Y')} ({today_weekday}), " + logger.info(f"parsed today_date: %s", today_date) + + except Exception as e: + logger.error("Error while parsing today's date: %s", e, exc_info=True) + logger.info(f"DATE: %s", today_date) + return today_date diff --git a/chatbot/utils/story_llama_utils.py b/chatbot/utils/story_llama_utils.py new file mode 100644 index 0000000..fec43c9 --- /dev/null +++ b/chatbot/utils/story_llama_utils.py @@ -0,0 +1,233 @@ +import json +import secrets +import traceback +from datetime import datetime +from chatbot.utils.audio_provider_utils import text_translate_provider +from shikshalokam.models import Project, ProjectStatus, ProjectVernacular, Task +import logging + +logger = logging.getLogger('django') + + +def create_project(response_json, title, objective, story, profile, problem_statement, project_id, language, + voice_provider, action_steps=None): + try: + resource_name = response_json.get('resource_name', '') + resource_link = response_json.get('resource_link', '') + duration = story.other_params.get('duration', '') if story.other_params else '' + keywords = response_json.get('keywords', '') + project_start_date = parse_datetime(response_json.get('project_start_date', '')) + project_end_date = parse_datetime(response_json.get('project_end_date', '')) + + if not project_id: + existing_project = Project.objects.filter(story=story).first() + if existing_project: + project_id = existing_project.project_id + print(f"Found existing project for story: {project_id}") + else: + project_id = generate_random_hex() + print(f"Generated new project_id: {project_id}") + + llm_source = "UNKNOWN" + if voice_provider and voice_provider.company_bot: + company_bot = voice_provider.company_bot + + provider = company_bot.provider or "unknown_provider" + model = company_bot.llm_model or "unknown_model" + + llm_source = f"{provider}:{model}" + + project, created = Project.objects.update_or_create( + project_id=project_id, + defaults={ + "story": story, + "author": profile, + "actual_title": title, + "actual_objective": objective, + "actual_duration": duration, + "project_status": ProjectStatus.SUBMITTED, + "actual_problem_statement": problem_statement, + "keywords": keywords, + 'project_source': llm_source, + 'program_source': llm_source, + "resource_name": resource_name, + "resource_link": resource_link, + "project_start_date": project_start_date, + "project_end_date": project_end_date, + "project_language": "en" + } + ) + + if created: + print("A new project was created.") + else: + print("The existing project was updated.") + + project.save() + + if action_steps: + if isinstance(action_steps, str): + action_steps = [action_steps] + if created: + for idx, step in enumerate(action_steps): + cleaned_step = step.strip() + if not cleaned_step: + continue + task = Task.objects.create( + project=project, + task_id=generate_random_hex(8), + parent_task_id=None, + task_name=cleaned_step, + mandatory_task=None, + task_status="COMPLETED", + description=cleaned_step, + source=llm_source, + other_params={ + "order": idx + 1, + "generated_from": "action_steps" + }, + created_by=profile.user if hasattr(profile, "user") else None + ) + + if language != 'en': + task_data = {'task_name': cleaned_step} + create_task_vernacular( + task=task, language=language, voice_provider=voice_provider, task_data=task_data + ) + + print(f"{len(action_steps)} tasks created for project {project.project_id}") + + if language != 'en': + create_project_vernacular( + project=project, + language=language, + voice_provider=voice_provider, + project_data={ + 'actual_title': title, + 'actual_objective': objective, + 'actual_problem_statement': problem_statement, + 'actual_duration': duration, + 'keywords': keywords, + 'resource_name': resource_name, + 'resource_link': resource_link + }, + ) + + return story.id, story.content + + except Exception as e: + traceback.print_exc() + return "", "" + + +def create_project_vernacular(project, language, voice_provider, project_data): + """Create ProjectVernacular entry with translated project data""" + try: + print("create_project_vernacular") + translated_data = {} + + translatable_fields = [ + 'actual_title', 'actual_objective', 'actual_problem_statement', + 'keywords', 'resource_name' + ] + + for field in translatable_fields: + field_value = project_data.get(field, '') + if field_value and field_value != '': + translated_value = translate_field( + voice_provider=voice_provider, + message_body=field_value, + target_language=language + ) + translated_data[field] = translated_value + else: + translated_data[field] = field_value + + story = project.story + if story: + try: + story_translation = story.translations.get(language=language) + translated_data['actual_duration'] = story_translation.other_params.get( + 'duration', '' + ) if story_translation.other_params else '' + except: + translated_data['actual_duration'] = story.other_params.get( + 'duration', '' + ) if story.other_params else '' + else: + translated_data['actual_duration'] = '' + + translated_data['resource_link'] = project_data.get('resource_link', '') + + project_vernacular, created = ProjectVernacular.objects.update_or_create( + project=project, + language=language, + defaults={ + 'details': json.dumps({"project": translated_data}, ensure_ascii=False) + } + ) + + if created: + print(f"Created ProjectVernacular for language: {language}") + else: + print(f"Updated ProjectVernacular for language: {language}") + + return project_vernacular + + except Exception as e: + print(f"Error creating ProjectVernacular: {e}") + traceback.print_exc() + return None + + +def create_task_vernacular(task, language, voice_provider, task_data): + if language != 'en' and voice_provider: + translated_task_name = translate_field( + voice_provider=voice_provider, + message_body=task_data.get('task_name'), + target_language=language + ) + + ProjectVernacular.objects.update_or_create( + task=task, + language=language, + details=json.dumps({ + "task_name": translated_task_name, + "description": translated_task_name + }, ensure_ascii=False), + ) + + +def generate_random_hex(length=16): + return secrets.token_hex(length) + + +def parse_datetime(date_str): + try: + if date_str: + return datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + pass + return None + + +def translate_field(voice_provider, message_body, target_language, source_language="en"): + print(f"Trying to translate: {message_body}") + logger.info(f"Trying to translate: {message_body}") + if not message_body or message_body == '' or source_language == target_language: + logger.info( + f"Skipping translation; returning original message. body='{message_body}', source='{source_language}'," + f" target='{target_language}'") + + return message_body + + response = text_translate_provider( + voice_provider=voice_provider, message_body=message_body, target_language=target_language, + source_language=source_language + ) + if response.get('status') == 200: + logger.info(f"Got 200 response from translation service: {response.get('content')}") + return response.get('content') + else: + logger.info(f"Translation service returned non-200; using original text: {message_body}") + return message_body diff --git a/chatbot/utils/story_utils/base/story_update_utils.py b/chatbot/utils/story_utils/base/story_update_utils.py new file mode 100644 index 0000000..4a86b78 --- /dev/null +++ b/chatbot/utils/story_utils/base/story_update_utils.py @@ -0,0 +1,185 @@ +import json +from chatbot.models import StoryTranslation, Voice, VoiceType +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.story_utils.format_utils import get_formatted_story +from chatbot.utils.story_utils.story_utils import get_story_company_bot + + +def extract_update_data(request): + """Extract and organize update data from request""" + return { + 'other_params': request.data.get('other_params', {}), + 'formatted_content': request.data.get('formatted_content'), + 'session': request.data.get('session'), + 'access_token': request.data.get('access_token'), + 'flow': request.data.get('flow') + } + + +def get_or_create_translation(story, language, update_data): + """Get existing translation or create new one""" + translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=language, + defaults={ + 'title': story.title, + 'content': story.content or '', + 'other_params': update_data['other_params'].copy() + } + ) + return translation + + +def update_translation_fields(translation, update_data, language): + """Update translation fields with new data""" + if update_data.get('other_params'): + translation.other_params = update_data['other_params'].copy() + + if update_data.get('formatted_content'): + process_formatted_content( + translation, update_data['formatted_content'], + ) + + translation.save() + + +def process_formatted_content(translation, formatted_content): + """Process formatted content and extract/translate text - SIMPLIFIED VERSION""" + translation.formatted_content = formatted_content + + try: + if isinstance(formatted_content, str): + formatted_data = json.loads(formatted_content) + else: + formatted_data = formatted_content + + content_parts = [] + + for item in formatted_data: + if item.get('type') == 'paragraph' and item.get('data', {}).get('text'): + content_parts.append(item['data']['text']) + + if content_parts: + translation.content = '\n'.join(content_parts) + + except (json.JSONDecodeError, KeyError, TypeError) as e: + print(f"Error processing formatted_content: {e}") + + +def sync_to_main_story(story, translation, update_data, source_language): + """Sync translation changes back to main story (English)""" + flow = update_data.get('flow') + voice_provider = get_voice_provider('en', flow) + if not voice_provider: + print("Could not find voice provider") + return + + if update_data['other_params']: + print("Translating other_params to English") + english_other_params = translate_other_params( + story.other_params or {}, + update_data['other_params'], + voice_provider, + source_language + ) + story.other_params = english_other_params + + if update_data.get('formatted_content') and translation.content: + sync_content_to_story(story, translation, voice_provider, source_language) + + story.save() + + +def translate_other_params(current_params, new_params, voice_provider, source_language): + """Translate other_params fields back to English""" + english_params = current_params.copy() + + translatable_fields = ['challenges_faced', 'solutions_discussed', 'question_answers'] + non_translatable_fields = ['participants_count', 'discussion_date', 'flow', 'location'] + + for field in translatable_fields: + if field in new_params: + english_params[field] = translate_nested_structure( + new_params[field], voice_provider, source_language + ) + + for field in non_translatable_fields: + if field in new_params: + english_params[field] = new_params[field] + + return english_params + + +def translate_field_value(value, voice_provider, source_language): + """Translate a field value (handles both strings and lists)""" + if isinstance(value, list): + return [ + translate_field( + voice_provider=voice_provider, + message_body=item, + target_language='en', + source_language=source_language + ) for item in value + ] + else: + return translate_field( + voice_provider=voice_provider, + message_body=value, + target_language='en', + source_language=source_language + ) + + +def sync_content_to_story(story, translation, voice_provider, source_language): + """Sync content and formatted_content to main story - SIMPLIFIED VERSION""" + print("sync_content_to_story called") + english_content = translate_field( + voice_provider=voice_provider, + message_body=translation.content, + target_language='en', + source_language=source_language + ) + story.content = english_content + + story.formatted_content = get_formatted_story(story) + + +def get_voice_provider(language, flow): + """Get voice provider for specified language""" + print("Flow is : ", flow) + print("language is : ", language) + bot, validate_bot = get_story_company_bot(None, flow) + print("bot: ", bot) + if bot: + print("bot route: ", bot.route) + return Voice.objects.filter( + company_bot=bot, + type=VoiceType.TextToText, + language=language + ).first() + + +def translate_nested_structure(data, voice_provider, source_language): + """ + Generic handler for translating nested data structures (dicts and lists). + Recursively processes any combination of dicts, lists, and strings. + """ + if isinstance(data, dict): + return { + key: translate_nested_structure(value, voice_provider, source_language) + for key, value in data.items() + } + elif isinstance(data, list): + return [ + translate_nested_structure(item, voice_provider, source_language) + for item in data + ] + elif isinstance(data, str) and data.strip(): + return translate_field( + voice_provider=voice_provider, + message_body=data, + target_language='en', + source_language=source_language + ) + else: + return data diff --git a/chatbot/utils/story_utils/base/translation_mixins.py b/chatbot/utils/story_utils/base/translation_mixins.py new file mode 100644 index 0000000..872cd1f --- /dev/null +++ b/chatbot/utils/story_utils/base/translation_mixins.py @@ -0,0 +1,81 @@ +from chatbot.models import StoryTranslation + + +class LanguageDetectionMixin: + """Mixin for consistent language detection across serializers and views""" + + def detect_language(self, request, instance=None): + """Detect language using consistent logic""" + language = ( + request.query_params.get('language') or + request.META.get('HTTP_X_LANGUAGE') or + self._extract_language_from_accept_header(request) + ) + + if not language and instance: + print("Checking for fallback langugaeg") + translation_languages = list(instance.translations.values_list('language', flat=True)) + if translation_languages: + language = translation_languages[0] + else: + language = 'en' + + return language or 'en' + + def _extract_language_from_accept_header(self, request): + """Extract language from Accept-Language header""" + accept_language = request.META.get('HTTP_ACCEPT_LANGUAGE', '') + if accept_language: + languages = [] + for lang_part in accept_language.split(','): + lang = lang_part.strip().split(';')[0].split('-')[0] + if lang and lang != 'en': + languages.append(lang) + return languages[0] if languages else None + return None + + +class TranslationMixin(LanguageDetectionMixin): + """Mixin to handle story translations in serializers""" + + def apply_translation(self, data, instance): + """Apply translation to story data based on request language""" + request = self.context.get('request') + if not request: + return data + + language = self.detect_language(request, instance) + + if language == 'en' or instance.language == language: + return data + + try: + translation = instance.translations.get(language=language) + + data['title'] = translation.title + if translation.content: + data['content'] = translation.content + if translation.blurb: + data['blurb'] = translation.blurb + if translation.tweet: + data['tweet'] = translation.tweet + if translation.objective: + data['objective'] = translation.objective + if translation.action_steps: + data['action_steps'] = translation.action_steps + if translation.impact: + data['impact'] = translation.impact + if translation.micro_improvement: + data['micro_improvement'] = translation.micro_improvement + if translation.formatted_content: + data['formatted_content'] = translation.formatted_content + + if translation.other_params and data['other_params']: + data['other_params'].update(translation.other_params) + elif translation.other_params: + data['other_params'] = translation.other_params + + except StoryTranslation.DoesNotExist: + pass + + return data diff --git a/chatbot/utils/story_utils/challenges_utils.py b/chatbot/utils/story_utils/challenges_utils.py new file mode 100644 index 0000000..931ec85 --- /dev/null +++ b/chatbot/utils/story_utils/challenges_utils.py @@ -0,0 +1,84 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from chatbot.utils.story_utils.get_story_prompts import get_challenges_prompt +import json_repair +import logging + + +logger = logging.getLogger('django') + + + +def handle_challenges_solutions(challenges_faced, solutions_discussed, profile, messages): + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/chaupal-story-challenge') + else: + company_bot = CompanyBot.objects.get(route='/chaupal-story-challenge') + + system_context = get_challenges_prompt( + challenges_faced=challenges_faced, solutions_discussed=solutions_discussed, + company_bot=company_bot + ) + response_json = None + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + tool = company_bot.tool_context + if tool and isinstance(tool, str): + tool = json_repair.repair_json(tool, return_objects=True) + response_json = handle_bedrock_model( + system_prompt=system_context, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + tools=tool, company_bot=company_bot + ) + elif company_bot.provider == LLMProvider.OPENAI: + response_json = handle_openai_model( + system_prompt=system_context, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, + is_json_response=True + ) + if response_json and isinstance(response_json, str): + response_json = json_repair.repair_json(response_json, return_objects=True) + + if response_json and isinstance(response_json, dict): + extracted_data = response_json.pop("parameters", response_json.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response_json.clear() + response_json.update(extracted_data) + if (isinstance(response_json, dict) and response_json.get("type") and + "value" in response_json): + value = response_json.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json = value + logger.info(f"response_json: %s", response_json) + + reorder_steps = response_json.get('reorder_steps', []) + if reorder_steps and isinstance(reorder_steps, str): + reorder_steps = json_repair.repair_json(reorder_steps, return_objects=True) + if reorder_steps and not isinstance(reorder_steps, list): + logger.info('reordersteps is not a list, getting values') + if reorder_steps.get('value'): + reorder_steps = reorder_steps.get('value') + + logger.info(f"reorder_steps: %s", reorder_steps) + + seen = set() + new_solutions_discussed = [] + + for step in reorder_steps: + matched = step.get('solution_matched', []) + if isinstance(matched, list) and matched: + for solution in matched: + if solution and solution not in seen: + seen.add(solution) + new_solutions_discussed.append(solution) + elif isinstance(matched, str) and matched: + if matched not in seen: + seen.add(matched) + new_solutions_discussed.append(matched) + + if not new_solutions_discussed or len(new_solutions_discussed) == 0: + new_solutions_discussed = solutions_discussed + else: + new_solutions_discussed = solutions_discussed + + return challenges_faced, new_solutions_discussed diff --git a/chatbot/utils/story_utils/chaupal/chaupal_story_tasks.py b/chatbot/utils/story_utils/chaupal/chaupal_story_tasks.py new file mode 100644 index 0000000..1f7a165 --- /dev/null +++ b/chatbot/utils/story_utils/chaupal/chaupal_story_tasks.py @@ -0,0 +1,457 @@ +import json +import traceback +import logging +import re +from chatbot.exceptions.story_exceptions import StoryDomainError, StoryValidationError, StoryError, StorySaveError +from chatbot.models import StoryStatusChoices, Story, Voice, VoiceType, StoryTranslation +from chatbot.models.geo_models import ProfileAddress +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.story_utils.challenges_utils import handle_challenges_solutions +from chatbot.utils.story_utils.format_utils import clean_escaped_text +from chatbot.utils.transliterate_utils import transliterate_text, get_transliteration_output +import json_repair + +logger = logging.getLogger('django') + + +def is_english_text(text): + """Check if text contains only English characters (a-z, A-Z, numbers, punctuation, spaces)""" + if not text or str(text).strip() == '': + logger.info(f"[ENGLISH CHECK] Received empty or blank text. Treating as English. text='{text}'") + return True + + original_text = str(text) + logger.info(f"[ENGLISH CHECK] Starting English validation. text='{original_text}'") + + # Remove common punctuation and numbers + cleaned_text = re.sub( + r'[0-9\s\.,\!\?\-\(\)\[\]\{\}\"\'\:\;\@\#\$\%\^\&\*\+\=\_\|\\\/<>~`]', + '', + original_text + ) + logger.info(f"[ENGLISH CHECK] Cleaned text after removing numbers & punctuation: '{cleaned_text}'") + + is_english = bool(re.match(r'^[a-zA-Z]*$', cleaned_text)) + + if is_english: + logger.info(f"[ENGLISH CHECK] Text identified as English. text='{original_text}', cleaned='{cleaned_text}'") + else: + logger.info(f"[ENGLISH CHECK] Non-English characters detected. text='{original_text}', cleaned='{cleaned_text}'") + + return is_english + + +def translate_to_english_if_needed(text, voice_provider, source_language): + """Translate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to translate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to translate. The data {text} is already in english.") + return text + + try: + if voice_provider: + translated = translate_field( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language + ) + logger.info(f"Translated data to english: {translated}.") + return translated + else: + logger.info(f"No voice provider available for translation. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error translating to English: {e}") + return text + + +def transliterate_to_english_if_needed(text, voice_provider, source_language): + """Transliterate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to transliterate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to transliterate. The data {text} is already in english.") + return text + + try: + if voice_provider: + is_sentence = ' ' in text + transliterated = transliterate_text( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language, + is_sentence=is_sentence + ) + logger.info(f"Transliterated data to english: {transliterated}.") + return get_transliteration_output(data=transliterated) + else: + logger.info(f"No voice provider available for transliteration. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error transliterating to English: {e}") + return text + + +def normalize_list_field(value): + if isinstance(value, str): + value = value.strip() + if value in ("[]", ""): + return [] + try: + parsed = json.loads(value) + if isinstance(parsed, list): + return parsed + except Exception: + return [value] + return value + + +def save_chaupal_report( + response_json_story, language, company_bot, voice_provider, profile, session, combined_reason, flow=None, + messages=[] +): + try: + # Get voice providers for translation/transliteration + translation_voice_provider = voice_provider + transliteration_voice_provider = None + + if company_bot and language != 'en': + transliteration_voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + is_within_domain = response_json_story.get('is_within_domain', True) + + if not is_within_domain: + raise StoryDomainError() + + # Extract and translate fields to English + raw_title = response_json_story.get('title', '') + english_title = clean_escaped_text( + text=translate_to_english_if_needed(raw_title, translation_voice_provider, language) + ) + + # Handle challenges and solutions (can be arrays) + raw_challenges_faced = response_json_story.get('challenges_faced', []) + raw_solutions_discussed = response_json_story.get('solutions_discussed', []) + + raw_challenges_faced = normalize_list_field(raw_challenges_faced) + raw_solutions_discussed = normalize_list_field(raw_solutions_discussed) + + # Translate challenges and solutions + if isinstance(raw_challenges_faced, list): + english_challenges_faced = [ + translate_to_english_if_needed(challenge, translation_voice_provider, language) + for challenge in raw_challenges_faced + ] + else: + english_challenges_faced = translate_to_english_if_needed(raw_challenges_faced, translation_voice_provider, + language) + + if isinstance(raw_solutions_discussed, list): + english_solutions_discussed = [ + translate_to_english_if_needed(solution, translation_voice_provider, language) + for solution in raw_solutions_discussed + ] + else: + english_solutions_discussed = translate_to_english_if_needed(raw_solutions_discussed, + translation_voice_provider, language) + + # Transliterate personal information fields + raw_user_name = response_json_story.get('user_name', '') + raw_user_location = response_json_story.get('location', '') + raw_organization = response_json_story.get('organization', '') + raw_remarks = response_json_story.get('remarks', '') + + user_name = transliterate_to_english_if_needed(raw_user_name, transliteration_voice_provider, language) + user_location = transliterate_to_english_if_needed(raw_user_location, transliteration_voice_provider, language) + organization = transliterate_to_english_if_needed(raw_organization, transliteration_voice_provider, language) + remarks = translate_to_english_if_needed(raw_remarks, translation_voice_provider, language) + + participants_count = response_json_story.get('participants_count', {}) + if isinstance(participants_count, str): + try: + participants_count = json.loads(participants_count) + except Exception: + participants_count = { + 'total': participants_count, + 'women': '', + 'men': '', + 'children': '' + } + + # --- Override total participant count if possible --- + try: + def safe_int(value): + """Convert to int if value contains digits, else return 0""" + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + match = re.search(r'\d+', value) + if match: + return int(match.group()) + return 0 + + men = safe_int(participants_count.get('men')) + women = safe_int(participants_count.get('women')) + children = safe_int(participants_count.get('children')) + + total = men + women + children + # Only override if total is greater than 0 + if total > 0: + participants_count['total'] = total + except Exception as e: + logger.warning(f"Error overriding total participants count: {e}") + + discussion_date = response_json_story.get('discussion_date', '') + + # Handle nested objects with transliteration + raw_pri_member = response_json_story.get('pri_member', {'name': '', 'designation': ''}) + pri_member = { + 'name': transliterate_to_english_if_needed(raw_pri_member.get('name', ''), transliteration_voice_provider, + language), + 'designation': transliterate_to_english_if_needed(raw_pri_member.get('designation', ''), + transliteration_voice_provider, language) + } + + raw_school_representative = response_json_story.get('school_representative', {'name': '', 'designation': ''}) + school_representative = { + 'name': transliterate_to_english_if_needed(raw_school_representative.get('name', ''), + transliteration_voice_provider, language), + 'designation': transliterate_to_english_if_needed(raw_school_representative.get('designation', ''), + transliteration_voice_provider, language) + } + + if english_solutions_discussed and len(english_solutions_discussed) > 0 and english_challenges_faced and len( + english_challenges_faced) > 0: + english_challenges_faced, english_solutions_discussed = handle_challenges_solutions( + challenges_faced=english_challenges_faced, solutions_discussed=english_solutions_discussed, + profile=profile, messages=messages + ) + + if profile: + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + location = ", ".join(location_parts) + else: + location = user_location + else: + location = user_location + + if isinstance(english_challenges_faced, str): + english_challenges_faced = [english_challenges_faced] + + if not isinstance(english_challenges_faced, list) or not english_challenges_faced: + raise StoryValidationError() + + + other_params = { + 'challenges_faced': list(english_challenges_faced) if isinstance( + english_challenges_faced, list) else english_challenges_faced, + 'solutions_discussed': list(english_solutions_discussed) if isinstance( + english_solutions_discussed, list) else english_solutions_discussed, + 'user_name': user_name, + 'location': location, + 'organization': organization, + 'participants_count': dict(participants_count) if isinstance( + participants_count, dict) else participants_count, + 'discussion_date': discussion_date, + 'pri_member': dict(pri_member) if isinstance(pri_member, dict) else pri_member, + 'school_representative': dict(school_representative) if isinstance( + school_representative, dict) else school_representative, + 'remarks': remarks, + 'flow': flow + } + + story = Story.objects.filter(session=session).first() + if story: + story.title = english_title + story.other_params = other_params + story.stage = StoryStatusChoices.COMPLETED + story.location = location + story.validation_logs = combined_reason + story.language = 'en' + else: + story = Story( + title=english_title, + author=profile, + session=session, + stage=StoryStatusChoices.COMPLETED, + location=location, + validation_logs=combined_reason, + language='en', + other_params=other_params + ) + story.save() + story.refresh_from_db() + + if language != 'en': + create_chaupal_translation( + story=story, + language=language, + english_title=english_title, + english_challenges_faced=english_challenges_faced, + english_solutions_discussed=english_solutions_discussed, + voice_provider=voice_provider, + company_bot=company_bot, + other_data={ + 'user_name': user_name, + 'organization': organization, + 'location': location, + 'participants_count': participants_count, + 'pri_member': pri_member, + 'school_representative': school_representative, + 'remarks': remarks + } + ) + + return story, None + + except StoryError: + raise + + except Exception as e: + logger.error('Error Occurred: %s', e, exc_info=True) + traceback.print_exc() + raise StorySaveError() + + +def create_chaupal_translation(story, language, english_title, english_challenges_faced, english_solutions_discussed, + voice_provider, company_bot, other_data): + """Create translation for chaupal report""" + try: + translated_title = translate_field( + voice_provider=voice_provider, message_body=english_title, target_language=language + ) + + remarks = other_data.get('remarks', '') + translated_remarks = '' + if remarks and remarks.strip(): + translated_remarks = translate_field( + voice_provider=voice_provider, message_body=remarks, target_language=language + ) + + if isinstance(english_challenges_faced, str): + english_challenges_faced = json_repair.repair_json(english_challenges_faced, return_objects=True) + + translated_challenges_faced = [ + translate_field( + voice_provider=voice_provider, + message_body=challenge, + target_language=language + ) + for challenge in english_challenges_faced + ] + + if isinstance(english_solutions_discussed, str): + english_solutions_discussed = json_repair.repair_json(english_solutions_discussed, return_objects=True) + + translated_solutions_discussed = [ + translate_field( + voice_provider=voice_provider, + message_body=solution, + target_language=language + ) + for solution in english_solutions_discussed + ] + + import copy + translated_other_params = copy.deepcopy(story.other_params) if story.other_params else {} + translated_other_params.update({ + 'challenges_faced': translated_challenges_faced, + 'solutions_discussed': translated_solutions_discussed, + 'remarks': translated_remarks + }) + + voice_transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + for field_name in ['user_name', 'organization', 'location']: + field_value = other_data.get(field_name, '') + if field_value and field_value != '': + is_sentence = ' ' in field_value + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=field_value, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + translated_other_params[field_name] = get_transliteration_output(data=transliterated) + + participants_count = other_data.get('participants_count', {}) + if participants_count: + translated_other_params['participants_count'] = participants_count + + pri_member = other_data.get('pri_member', {'name': '', 'designation': ''}) + if pri_member: + translated_pri_member = {} + for field_name in ['name', 'designation']: + field_value = pri_member.get(field_name, '') + if field_value and field_value != '': + is_sentence = ' ' in field_value + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=field_value, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + translated_pri_member[field_name] = get_transliteration_output(data=transliterated) + else: + translated_pri_member[field_name] = field_value + translated_other_params['pri_member'] = translated_pri_member + + school_representative = other_data.get('school_representative', {'name': '', 'designation': ''}) + if school_representative: + translated_school_representative = {} + for field_name in ['name', 'designation']: + field_value = school_representative.get(field_name, '') + if field_value and field_value != '': + is_sentence = ' ' in field_value + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=field_value, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + translated_school_representative[field_name] = get_transliteration_output(data=transliterated) + else: + translated_school_representative[field_name] = field_value + translated_other_params['school_representative'] = translated_school_representative + + discussion_date = other_data.get('discussion_date', '') + if discussion_date: + translated_other_params['discussion_date'] = discussion_date + + translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=language, + defaults={ + 'title': translated_title, + 'content': '', + 'other_params': translated_other_params + } + ) + + if not created: + translation.title = translated_title + translation.other_params = translated_other_params + translation.save() + + logger.info(f"Created/Updated chaupal translation for story {story.id} in language {language}") + return translation + + except Exception as e: + logger.error(f'Error creating chaupal translation: %s', e, exc_info=True) + return None diff --git a/chatbot/utils/story_utils/common/generic_story_tasks.py b/chatbot/utils/story_utils/common/generic_story_tasks.py new file mode 100644 index 0000000..af21766 --- /dev/null +++ b/chatbot/utils/story_utils/common/generic_story_tasks.py @@ -0,0 +1,797 @@ +import logging +import json +import re +from chatbot.exceptions.story_exceptions import StoryDomainError, StorySaveError, StoryError +from chatbot.models import StoryStatusChoices, Story, Voice, VoiceType, StoryTranslation, Profile +from chatbot.models.geo_models import ProfileAddress +from chatbot.models.story_vernacular_model import StoryVernacular +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.story_utils.format_utils import clean_escaped_text, get_formatted_story +from chatbot.utils.transliterate_utils import transliterate_text, get_transliteration_output + +logger = logging.getLogger('django') + + +def is_english_text(text): + """Check if text contains only English characters (a-z, A-Z, numbers, punctuation, spaces)""" + if not text or str(text).strip() == '': + logger.info(f"[ENGLISH CHECK] Received empty or blank text. Treating as English. text='{text}'") + return True + + original_text = str(text) + logger.info(f"[ENGLISH CHECK] Starting English validation. text='{original_text}'") + + # Remove common punctuation and numbers + cleaned_text = re.sub( + r'[0-9\s\.,\!\?\-\(\)\[\]\{\}\"\'\:\;\@\#\$\%\^\&\*\+\=\_\|\\\/<>~`]', + '', + original_text + ) + logger.info(f"[ENGLISH CHECK] Cleaned text after removing numbers & punctuation: '{cleaned_text}'") + + is_english = bool(re.match(r'^[a-zA-Z]*$', cleaned_text)) + + if is_english: + logger.info(f"[ENGLISH CHECK] Text identified as English. text='{original_text}', cleaned='{cleaned_text}'") + else: + logger.info(f"[ENGLISH CHECK] Non-English characters detected. text='{original_text}', cleaned='{cleaned_text}'") + + return is_english + + +def translate_to_english_if_needed(text, voice_provider, source_language): + """Translate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to translate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to translate. The data {text} is already in english.") + return text + + try: + if voice_provider: + translated = translate_field( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language + ) + logger.info(f"Translated data to english: {translated}.") + return translated + else: + logger.info(f"No voice provider available for translation. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error translating to English: {e}") + return text + + +def transliterate_to_english_if_needed(text, voice_provider, source_language): + """Transliterate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to transliterate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to transliterate. The data {text} is already in english.") + return text + + try: + if voice_provider: + is_sentence = ' ' in text + transliterated = transliterate_text( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language, + is_sentence=is_sentence + ) + logger.info(f"Transliterated data to english: {transliterated}.") + return get_transliteration_output(data=transliterated) + else: + logger.info(f"No voice provider available for transliteration. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error transliterating to English: {e}") + return text + + +def translate_nested_to_english(data, voice_provider, transliteration_voice_provider, source_language, field_path=""): + if isinstance(data, dict): + translated_dict = {} + for key, value in data.items(): + current_path = f"{field_path}.{key}" if field_path else key + logger.info(f"DEBUG: Processing {key} = '{value}' (type: {type(value)})") + + if isinstance(value, str) and value.strip(): + personal_info_fields = ['name', 'user_name', 'location', 'organization', 'designation', 'district', + 'block'] + if key.lower() in personal_info_fields or any(field in key.lower() for field in personal_info_fields): + translated_dict[key] = transliterate_to_english_if_needed(value, transliteration_voice_provider, + source_language) + else: + translated_dict[key] = translate_to_english_if_needed(value, voice_provider, source_language) + elif isinstance(value, (dict, list)): + translated_dict[key] = translate_nested_to_english(value, voice_provider, + transliteration_voice_provider, source_language, + current_path) + else: + translated_dict[key] = value + return translated_dict + elif isinstance(data, list): + translated_list = [] + for i, item in enumerate(data): + if isinstance(item, str) and item.strip(): + translated_list.append(translate_to_english_if_needed(item, voice_provider, source_language)) + elif isinstance(item, (dict, list)): + translated_list.append( + translate_nested_to_english(item, voice_provider, transliteration_voice_provider, source_language, + f"{field_path}[{i}]")) + else: + translated_list.append(item) + return translated_list + else: + return data + + +def save_generic_story( + response_json_story, language, voice_provider, profile, session, combined_reason, flow=None, project_id=None, + company_bot=None, exclude_fields=None +): + try: + import copy + if exclude_fields is None: + exclude_fields = [] + exclude_fields_set = set(exclude_fields) if exclude_fields else set() + + translation_voice_provider = voice_provider + transliteration_voice_provider = None + + if company_bot and language != 'en': + transliteration_voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + if isinstance(response_json_story, str): + try: + response_json_story = json.loads(response_json_story) + except json.JSONDecodeError: + raise StorySaveError() + + if not isinstance(response_json_story, dict): + raise StorySaveError() + + print("For saving response_json_story: ", response_json_story) + + def parse_json_strings(data): + if isinstance(data, dict): + parsed_data = {} + for key, value in data.items(): + if isinstance(value, str) and value.strip(): + stripped_value = value.strip() + if ((stripped_value.startswith('[') and stripped_value.endswith(']')) or + (stripped_value.startswith('{') and stripped_value.endswith('}'))): + try: + parsed_data[key] = json.loads(stripped_value) + except json.JSONDecodeError: + parsed_data[key] = value + else: + parsed_data[key] = value + else: + parsed_data[key] = parse_json_strings(value) if isinstance(value, (dict, list)) else value + return parsed_data + elif isinstance(data, list): + return [parse_json_strings(item) for item in data] + else: + return data + + response_json_story = parse_json_strings(response_json_story) + + story = Story.objects.filter(session=session).first() + + is_within_domain = response_json_story.get('is_within_domain', True) + + if not is_within_domain: + raise StoryDomainError() + + if story and story.other_params: + other_params = copy.deepcopy(story.other_params) + other_params.pop('_english_snapshot', None) + else: + other_params = {} + + previous_english_snapshot = {k: v for k, v in other_params.items()} + + user_name = None + if isinstance(profile, Profile): + user_name = profile.first_name if profile and profile.first_name else '' + + elif isinstance(profile, dict): + user_name = profile.get("first_name", "") if profile and profile.get("first_name") else '' + + fallback_location = "" + + if isinstance(profile, Profile): + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + fallback_location = ", ".join(location_parts) + elif isinstance(profile, dict): + address = profile.get("profile_address", []) + if len(address) > 0: + location_parts = filter(None, [address[0].get("block"), address[0].get("district"), address[0].get("state")]) + fallback_location = ", ".join(location_parts) + + other_params['flow'] = flow + + if 'user_name' not in exclude_fields_set: + other_params['user_name'] = user_name + + STORY_MODEL_FIELDS = { + 'title', 'content', 'tweet', 'objective', 'action_steps', + 'impact', 'micro_improvement', 'blurb', 'language', 'stage', + 'other_params', 'location', 'validation_logs' + } + + NON_TRANSLATABLE_FIELDS = {'flow', 'id', 'uuid', 'status', 'type', 'mode', 'version'} + PERSONAL_INFO_FIELDS = {'name', 'user_name', 'location', 'organization', 'designation', 'district', 'block'} + + for key, value in response_json_story.items(): + if key in exclude_fields_set: + continue + if key not in STORY_MODEL_FIELDS: + if isinstance(value, dict) or isinstance(value, list): + other_params[key] = translate_nested_to_english( + value, translation_voice_provider, transliteration_voice_provider, language, key + ) + elif isinstance(value, str) and value.strip(): + if key.lower() in NON_TRANSLATABLE_FIELDS: + other_params[key] = value + elif key.lower() in PERSONAL_INFO_FIELDS: + other_params[key] = transliterate_to_english_if_needed(value, transliteration_voice_provider, + language) + else: + other_params[key] = translate_to_english_if_needed(value, translation_voice_provider, language) + else: + other_params[key] = value + + print(f"Story other_params before save: {other_params}") + + story_fields_to_update = {} + english_title=None + if 'title' not in exclude_fields_set: + print("Title not in excluded set") + if 'title' in response_json_story: + raw_title = response_json_story.get('title', '') + print("Raw title: ", raw_title) + english_title = clean_escaped_text( + text=translate_to_english_if_needed(raw_title, translation_voice_provider, language) + ) + print("english_title: ", english_title) + if not english_title and company_bot: + print("trying to get from story vernacular") + try: + story_vernacular = StoryVernacular.objects.filter( + company_bot=company_bot, language='en' + ).first() + if story_vernacular and story_vernacular.translation_json: + vernacular_title = story_vernacular.translation_json.get('title') + if vernacular_title: + english_title = vernacular_title + logger.info(f"Used StoryVernacular English title") + except Exception as e: + logger.info(f"Could not get title from StoryVernacular: {e}") + if not english_title or not english_title.strip(): + print("Default title") + english_title = 'Improvement_story' + logger.info("Using default title: Improvement_story") + + story_fields_to_update['title'] = english_title + + print("english_title: ", english_title) + if 'content' in response_json_story and 'content' not in exclude_fields_set: + raw_content = response_json_story.get('content', '') + story_fields_to_update['content'] = clean_escaped_text( + text=translate_to_english_if_needed(raw_content, translation_voice_provider, language) + ) + + if 'objective' in response_json_story and 'objective' not in exclude_fields_set: + raw_objective = response_json_story.get('objective', '') + story_fields_to_update['objective'] = clean_escaped_text( + text=translate_to_english_if_needed(raw_objective, translation_voice_provider, language) + ) + + if 'impact' in response_json_story and 'impact' not in exclude_fields_set: + raw_impact = response_json_story.get('impact', '') + story_fields_to_update['impact'] = clean_escaped_text( + text=translate_to_english_if_needed(raw_impact, translation_voice_provider, language) + ) + + if 'blurb' in response_json_story and 'blurb' not in exclude_fields_set: + raw_blurb = response_json_story.get('blurb', '') + story_fields_to_update['blurb'] = clean_escaped_text( + text=translate_to_english_if_needed(raw_blurb, translation_voice_provider, language) + ) + + if 'tweet' in response_json_story and 'tweet' not in exclude_fields_set: + story_fields_to_update['tweet'] = response_json_story.get('tweet', '') + + if 'micro_improvement' in response_json_story and 'micro_improvement' not in exclude_fields_set: + story_fields_to_update['micro_improvement'] = response_json_story.get('micro_improvement', '') + + if 'action_steps' in response_json_story and 'action_steps' not in exclude_fields_set: + raw_action_steps = response_json_story.get('action_steps', []) + if isinstance(raw_action_steps, str): + story_fields_to_update['action_steps'] = translate_to_english_if_needed(raw_action_steps, + translation_voice_provider, + language) + elif isinstance(raw_action_steps, list): + story_fields_to_update['action_steps'] = [ + translate_to_english_if_needed(step, translation_voice_provider, language) + for step in raw_action_steps + ] + else: + story_fields_to_update['action_steps'] = raw_action_steps + + if 'location' in response_json_story and 'location' not in exclude_fields_set: + raw_location = response_json_story.get('location', fallback_location) + if raw_location and raw_location.strip(): + story_fields_to_update['location'] = transliterate_to_english_if_needed(raw_location, + transliteration_voice_provider, + language) + else: + story_fields_to_update['location'] = "" + + story_fields_to_update.update({ + 'author': profile if isinstance(profile, Profile) else Profile.objects.filter(id=profile.get("id")).first(), + 'session': session, + 'language': 'en', + 'stage': StoryStatusChoices.COMPLETED, + 'other_params': other_params, + 'validation_logs': combined_reason + }) + + if story: + for field, value in story_fields_to_update.items(): + if hasattr(story, field): + setattr(story, field, value) + else: + default_story_fields = { + 'title': 'Improvement_story', + 'content': '', + 'tweet': '', + 'objective': '', + 'action_steps': [], + 'impact': '', + 'micro_improvement': '', + 'blurb': '', + 'location': '', + } + default_story_fields.update(story_fields_to_update) + story = Story(**default_story_fields) + + story.save() + print(f"Story saved with other_params: {story.other_params}") + + if language != 'en': + english_data = {} + for field in ['title', 'content', 'tweet', 'objective', 'action_steps', 'impact', 'micro_improvement', 'blurb']: + if field in story_fields_to_update: + english_data[field] = story_fields_to_update[field] + + create_generic_story_translation( + story=story, + language=language, + english_data=english_data, + voice_provider=voice_provider, + flow=flow, + company_bot=company_bot, + response_json_story=response_json_story, + previous_english_snapshot=previous_english_snapshot, + exclude_fields=exclude_fields_set + ) + + logger.info(f"Successfully saved generic story for flow {flow}, session {session}") + + problem_statement = "" + if 'problem_statement' in response_json_story and 'problem_statement' not in exclude_fields_set: + raw_problem_statement = response_json_story.get('problem_statement', '') + problem_statement = clean_escaped_text( + text=translate_to_english_if_needed(raw_problem_statement, translation_voice_provider, language) + ) + + return story, problem_statement + + except StoryError: + raise + + except Exception as e: + logger.error("Error saving generic story: %s", e, exc_info=True) + raise StorySaveError() + + +def create_generic_story_translation(story, language, english_data, voice_provider, flow, company_bot, + response_json_story, previous_english_snapshot, exclude_fields=None): + try: + + import copy + if exclude_fields is None: + exclude_fields = set() + + print(f"Creating translation for language: {language}") + print(f"Story other_params: {story.other_params}") + + existing_translation = StoryTranslation.objects.filter( + story=story, + language=language + ).first() + + if existing_translation and existing_translation.other_params: + translated_other_params = copy.deepcopy(existing_translation.other_params) + else: + translated_other_params = {} + + logger.info( + f"[TRANSLATION STATE] " + f"existing_translation={'YES' if existing_translation else 'NO'} | " + f"existing_keys={list(translated_other_params.keys())}" + ) + + TRANSLATABLE_FIELDS = ['title', 'content', 'tweet', 'objective', 'impact', 'micro_improvement', 'blurb'] + + translated_data = {} + + for field in TRANSLATABLE_FIELDS: + if field in exclude_fields: + continue + should_translate = False + field_value = None + + if field in english_data and english_data[field]: + should_translate = True + field_value = english_data[field] + elif existing_translation and not getattr(existing_translation, field, None): + story_value = getattr(story, field, None) + if story_value: + should_translate = True + field_value = story_value + + if should_translate and field_value: + try: + translated_data[field] = translate_field( + voice_provider=voice_provider, + message_body=field_value, + target_language=language + ) + except Exception as e: + logger.info(f"Could not translate field {field}: {e}") + translated_data[field] = field_value + + action_steps_to_translate = None + if 'action_steps' in english_data: + action_steps_to_translate = english_data['action_steps'] + elif existing_translation and not existing_translation.action_steps: + if story.action_steps: + action_steps_to_translate = story.action_steps + + if action_steps_to_translate: + if isinstance(action_steps_to_translate, str) and action_steps_to_translate.strip(): + try: + translated_data['action_steps'] = translate_field( + voice_provider=voice_provider, + message_body=action_steps_to_translate, + target_language=language + ) + except Exception as e: + logger.info(f"Could not translate action_steps: {e}") + translated_data['action_steps'] = action_steps_to_translate + elif isinstance(action_steps_to_translate, list): + translated_action_steps = [] + for action_step in action_steps_to_translate: + if action_step: + try: + translated_step = translate_field( + voice_provider=voice_provider, + message_body=str(action_step), + target_language=language + ) + translated_action_steps.append(translated_step) + except Exception as e: + logger.info(f"Could not translate action step: {e}") + translated_action_steps.append(str(action_step)) + translated_data['action_steps'] = translated_action_steps + + print(f"Starting with translated_other_params: {translated_other_params}") + + NON_TRANSLATABLE_FIELDS = {'flow', 'id', 'uuid', 'status', 'type', 'mode', 'version', '_english_snapshot'} + + for key in NON_TRANSLATABLE_FIELDS: + if key in story.other_params and key != '_english_snapshot': + translated_other_params[key] = story.other_params[key] + + def is_translatable_text(value, key=""): + if not isinstance(value, str) or not value.strip(): + return False + + value_str = str(value).strip() + + technical_fields = ['flow', 'id', 'uuid', 'status', 'type', 'mode', 'version'] + if key.lower() in technical_fields: + return False + + if value_str.isdigit(): + return False + + if (value_str.startswith(('http://', 'https://', 'ftp://', 'mailto:')) or + value_str.count('@') == 1 and '.' in value_str.split('@')[1] or + value_str.startswith(('.', '/', '\\')) or + value_str.lower().endswith(('.jpg', '.png', '.pdf', '.doc', '.xls', '.mp4', '.mp3'))): + return False + + alpha_count = sum(1 for c in value_str if c.isalpha()) + if alpha_count < len(value_str) * 0.5: + return False + + return True + + def translate_nested_structure(data, field_path=""): + if isinstance(data, dict): + translated_dict = {} + for key, value in data.items(): + current_path = f"{field_path}.{key}" if field_path else key + + if not isinstance(value, (str, dict, list)): + translated_dict[key] = value + elif isinstance(value, str) and is_translatable_text(value, key): + try: + translated_value = translate_field( + voice_provider=voice_provider, + message_body=value, + target_language=language + ) + translated_dict[key] = translated_value + except Exception as e: + logger.info(f"Could not translate {current_path}: {e}") + translated_dict[key] = value + elif isinstance(value, (dict, list)): + translated_dict[key] = translate_nested_structure(value, current_path) + else: + translated_dict[key] = value + return translated_dict + elif isinstance(data, list): + return [translate_nested_structure(item, field_path) for i, item in enumerate(data)] + + elif isinstance(data, str) and is_translatable_text(data, field_path): + return translate_field( + voice_provider=voice_provider, + message_body=data, + target_language=language + ) + else: + return data + + fields_to_translate = [] + + for key, english_value in story.other_params.items(): + if key in NON_TRANSLATABLE_FIELDS: + continue + + previous_english_value = previous_english_snapshot.get(key) + + english_changed = (english_value != previous_english_value) + translation_missing = (key not in translated_other_params) + + logger.info( + f"[FIELD CHECK] key={key} | " + f"english_changed={english_changed} | " + f"translation_missing={translation_missing}" + ) + + if english_changed or translation_missing: + fields_to_translate.append(key) + logger.info(f"[WILL TRANSLATE] key={key}") + else: + logger.info(f"[SKIP - NO CHANGE] key={key}") + + for key in fields_to_translate: + english_value = story.other_params[key] + + print(f"[TRANSLATING] key={key}, value={english_value}, type={type(english_value)}") + + if isinstance(english_value, (dict, list)): + print(f"[NESTED] Translating nested structure for {key}") + translated_other_params[key] = translate_nested_structure(english_value, key) + elif isinstance(english_value, str) and english_value.strip(): + if is_translatable_text(english_value, key): + try: + print(f"[API CALL] Calling translate_field for {key}: '{english_value}'") + translated_value = translate_field( + voice_provider=voice_provider, + message_body=english_value, + target_language=language + ) + print(f"[SUCCESS] Translated {key}: '{english_value}' -> '{translated_value}'") + translated_other_params[key] = translated_value + except Exception as e: + logger.error(f"[ERROR] Translation failed for {key}: {e}", exc_info=True) + print(f"[ERROR] Translation failed for {key}: {e}") + translated_other_params[key] = english_value + else: + print(f"[SKIP] {key} failed is_translatable_text check") + translated_other_params[key] = english_value + else: + print(f"[KEEP] {key} is not a translatable type") + translated_other_params[key] = english_value + + if 'duration' in story.other_params and 'duration' not in fields_to_translate: + if 'duration' not in translated_other_params: + duration_value = story.other_params.get('duration', '') + if duration_value and ' ' in str(duration_value): + try: + print(f"[DURATION] Translating duration: {duration_value}") + translated_other_params['duration'] = translate_field( + voice_provider=voice_provider, + message_body=str(duration_value), + target_language=language + ) + except Exception as e: + logger.info(f"Could not translate duration: {e}") + + if company_bot: + try: + voice_transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + transliterate_fields = [ + 'user_name', 'location', 'organization', 'designation', + 'district', 'block', 'village', 'panchayat', 'social_media' + ] + + for field_name in transliterate_fields: + if field_name in fields_to_translate or ( + field_name in story.other_params and field_name not in translated_other_params): + field_value = story.other_params.get(field_name, '') + if field_value and str(field_value).strip(): + try: + is_sentence = ' ' in str(field_value) + print(f"Transliterating {field_name}: {field_value}") + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=str(field_value), + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + translated_other_params[field_name] = get_transliteration_output(data=transliterated) + print(f"Transliterated to: {translated_other_params[field_name]}") + except Exception as e: + logger.info(f"Could not transliterate field {field_name}: {e}") + translated_other_params[field_name] = str(field_value) + + if story.location and ( + 'location' in fields_to_translate or 'location' not in translated_other_params): + try: + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=story.location, + target_language=language, + source_language='en', + is_sentence=' ' in story.location + ) + translated_other_params['location'] = get_transliteration_output(data=transliterated) + except Exception as e: + logger.info(f"Could not transliterate location: {e}") + + except Exception as e: + logger.info(f"Could not set up transliteration: {e}") + + print(f"Final translated_other_params: {translated_other_params}") + + translated_other_params.pop("_english_snapshot", None) + + if existing_translation: + update_fields = ['other_params'] + translation = existing_translation + translation.other_params = translated_other_params + + for field in TRANSLATABLE_FIELDS: + if field in translated_data: + setattr(translation, field, translated_data[field]) + update_fields.append(field) + + if 'action_steps' in translated_data: + translation.action_steps = translated_data['action_steps'] + update_fields.append('action_steps') + + if story.location and not translation.location: + if 'location' in translated_other_params: + translation.location = translated_other_params['location'] + else: + translation.location = story.location + update_fields.append('location') + + translation.save(update_fields=update_fields) + created = False + else: + defaults = { + 'title': '', + 'content': '', + 'tweet': '', + 'objective': '', + 'action_steps': [], + 'impact': '', + 'micro_improvement': '', + 'blurb': '', + 'location': '', + 'other_params': translated_other_params, + 'formatted_content': '' + } + + for field in TRANSLATABLE_FIELDS: + if field in translated_data: + defaults[field] = translated_data[field] + + if 'action_steps' in translated_data: + defaults['action_steps'] = translated_data['action_steps'] + + if 'location' in translated_other_params: + defaults['location'] = translated_other_params['location'] + + translation = StoryTranslation.objects.create( + story=story, + language=language, + **defaults + ) + created = True + + print(f"Translation saved with other_params: {translation.other_params}") + + try: + formatted_translation_content = get_formatted_story(translation) + if formatted_translation_content: + translation.formatted_content = formatted_translation_content + translation.save(update_fields=['formatted_content']) + except Exception as e: + logger.info(f"Could not format translation content: {e}") + + logger.info(f"Created/Updated generic translation for story {story.id} in language {language}") + return translation + + except Exception as e: + logger.error(f'Error creating generic translation: %s', e, exc_info=True) + return None + + +def get_generic_story_in_language(story, language='en'): + if language == 'en' or language == story.language: + return { + 'title': story.title, + 'content': story.content, + 'tweet': story.tweet, + 'objective': story.objective, + 'action_steps': story.action_steps, + 'impact': story.impact, + 'micro_improvement': story.micro_improvement, + 'blurb': story.blurb, + 'location': story.location, + 'other_params': story.other_params, + } + + try: + translation = story.translations.get(language=language) + return { + 'title': translation.title, + 'content': translation.content, + 'tweet': translation.tweet, + 'objective': translation.objective, + 'action_steps': translation.action_steps, + 'impact': translation.impact, + 'micro_improvement': translation.micro_improvement, + 'blurb': translation.blurb, + 'location': translation.location, + 'other_params': translation.other_params, + } + except StoryTranslation.DoesNotExist: + return get_generic_story_in_language(story, 'en') diff --git a/chatbot/utils/story_utils/format_utils.py b/chatbot/utils/story_utils/format_utils.py new file mode 100644 index 0000000..c9f7f85 --- /dev/null +++ b/chatbot/utils/story_utils/format_utils.py @@ -0,0 +1,51 @@ +import json +import random +import string +import json_repair + + +def format_response_json(response): + response_json = response.replace('\n', '').replace('\t', '').replace( + '\r', '').replace('\\n', '').replace('\\t', '').replace('\\r', '') + if '{' in response_json: + response_json = response_json[response_json.index('{'):] + last_char = response_json[-1] + if last_char != '}': + response_json += '}' + if isinstance(response_json, str): + response_json = json_repair.repair_json(response_json, return_objects=True) + + return response_json + + +def get_formatted_story(story): + if not story or not story.content: + return None + story_paragraphs = story.content.split("\n") + res = [] + for paragraph in story_paragraphs: + res.append( + { + 'id': generate_random_string(10), + 'type': 'paragraph', + 'data': + { + 'text': paragraph, + } + } + ) + return json.dumps(res) + + +def generate_random_string(length): + characters = string.ascii_letters + string.digits + rs = ''.join(random.choice(characters) for _ in range(length)) + return rs + + +def clean_escaped_text(text): + text = text.replace("\\'", "")# \' → ' + text = text.replace('\\"', '')# \" → " + text = text.replace("\\\\", "") # \\ → \ + print("Text: ", text) + return text diff --git a/chatbot/utils/story_utils/get_story_prompts.py b/chatbot/utils/story_utils/get_story_prompts.py new file mode 100644 index 0000000..9b237a8 --- /dev/null +++ b/chatbot/utils/story_utils/get_story_prompts.py @@ -0,0 +1,240 @@ +from jinja2 import Template +import json_repair +from chatbot.models import Profile, LLMProvider +from chatbot.models.geo_models import ProfileAddress +from chatbot.utils.sql_utils import get_todays_date + + +def get_creation_promt(company_bot, profile): + context = company_bot.context + + + address = [] + + if isinstance(profile, Profile): + address = ProfileAddress.objects.filter(profile=profile) + elif isinstance(profile, dict): + address = profile.get('profile_address', []) + + state_machines = company_bot.companystatemachine_set.all().order_by('step') + master_question = None + + if state_machines.exists(): + first_state_machine = state_machines.first() + if first_state_machine.bot_question and first_state_machine.bot_question.strip(): + master_question = first_state_machine.bot_question.strip() + + context_data = { + "profile": profile, + "address": address if address else [{}], + } + + if master_question: + context_data["master_question"] = master_question + + template = Template(company_bot.tag_context) + tag_context = template.render(context_data) + + end_context = company_bot.end_context + project_data = '' + today_date = get_todays_date(company_bot=company_bot) + + content_prompt = f""" + {context} + {tag_context} + {today_date} + {project_data} + """ if context else None + story_prompt = f""" + {end_context} + {tag_context} + {today_date} + {project_data} + """ if end_context else None + formatted_content_prompt = [] + formatted_story_prompt = [] + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + formatted_content_prompt = [ + { + 'text': content_prompt + }, + ] if content_prompt else None + formatted_story_prompt = [ + { + 'text': story_prompt + }, + ] if story_prompt else None + elif company_bot.provider == LLMProvider.OPENAI: + formatted_content_prompt = [ + { + 'role': 'system', + 'content': content_prompt + }, + ] if content_prompt else None + formatted_story_prompt = [ + { + 'role': 'system', + 'content': story_prompt + }, + ] if story_prompt else None + + return formatted_content_prompt, formatted_story_prompt, tag_context, project_data + + +def get_validation_prompt( + response_json_story, validate_bot, response_json_content, tag_context, project_data, profile +): + + address = [] + + if isinstance(profile, Profile): + address = ProfileAddress.objects.filter(profile=profile) + elif isinstance(profile, dict): + address = profile.get('profile_address', []) + + state_machines = validate_bot.companystatemachine_set.all().order_by('step') + master_question = None + + if state_machines.exists(): + first_state_machine = state_machines.first() + if first_state_machine.bot_question and first_state_machine.bot_question.strip(): + master_question = first_state_machine.bot_question.strip() + + validate_context_data = { + "story_json_output": response_json_story, + "profile": profile, + "address": address if address else [{}] + } + + if master_question: + validate_context_data["master_question"] = master_question + + validate_template = Template(validate_bot.tag_context) + validate_tag_context = validate_template.render(validate_context_data) + + today_date = get_todays_date(company_bot=validate_bot) + + validate_story_prompt = f""" + {validate_bot.end_context} + {validate_tag_context} + {tag_context} + {today_date} + {project_data} + """ if validate_bot.end_context else None + + validate_context_data = { + "story_json_output": response_json_content, + "profile": profile, + "address": address if address else [{}] + } + + if master_question: + validate_context_data["master_question"] = master_question + + validate_tag_context = validate_template.render(validate_context_data) + + validate_content_prompt = f""" + {validate_bot.context} + {validate_tag_context} + {tag_context} + {today_date} + {project_data} + """ if validate_bot.context else None + + if validate_bot.provider == LLMProvider.BEDROCK_CONVERSE: + validate_content_prompt = [ + { + 'text': validate_content_prompt + }, + ] if validate_content_prompt else None + validate_story_prompt = [ + { + 'text': validate_story_prompt + }, + ] if validate_story_prompt else None + elif validate_bot.provider == LLMProvider.OPENAI: + validate_content_prompt = [ + { + 'role': 'system', + 'content': validate_content_prompt + }, + ] if validate_content_prompt else None + validate_story_prompt = [ + { + 'role': 'system', + 'content': validate_story_prompt + }, + ] if validate_story_prompt else None + + return validate_content_prompt, validate_story_prompt + + +def get_chat_message(company_chats, company_bot): + messages = [] + ai_user = Profile.objects.get(id=1) + + if company_chats and company_chats[0].receiver != ai_user: + company_chats.pop(0) + for chat in company_chats: + user_message = chat.message + if chat.receiver == ai_user: + if chat.translated_message is not None and chat.translated_message != '': + user_message = chat.translated_message + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + messages.append({ + 'role': 'user', + 'content': [{'text': user_message}] + }) + elif company_bot.provider == LLMProvider.OPENAI: + messages.append({ + 'role': 'user', + 'content': user_message + }) + else: + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + messages.append({ + 'role': 'assistant', + 'content': [{'text': user_message}] + }) + elif company_bot.provider == LLMProvider.OPENAI: + messages.append({ + 'role': 'assistant', + 'content': user_message + }) + + + return messages + + +def get_tool_values(company_bot): + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + tool_story = tool_context.get('story_tool') + tool_content = tool_context.get('content_tool') + + return tool_content, tool_story + + +def get_challenges_prompt(challenges_faced, solutions_discussed, company_bot): + context_data = { + "challenges_faced": challenges_faced, + "solutions_discussed": solutions_discussed, + } + template = Template(company_bot.context) + updated_context = template.render(context_data) + content_prompt="" + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + content_prompt = [ + { + 'text': updated_context + }, + ] + elif company_bot.provider == LLMProvider.OPENAI: + content_prompt = [ + { + 'role': 'system', + 'content': updated_context + }, + ] + return content_prompt diff --git a/chatbot/utils/story_utils/mi_story_capture/mi_story_tasks.py b/chatbot/utils/story_utils/mi_story_capture/mi_story_tasks.py new file mode 100644 index 0000000..c5cf9d7 --- /dev/null +++ b/chatbot/utils/story_utils/mi_story_capture/mi_story_tasks.py @@ -0,0 +1,447 @@ +import traceback +import logging +import re + +from chatbot.exceptions.story_exceptions import StoryDomainError, StoryValidationError, StorySaveError, StoryError +from chatbot.models import StoryStatusChoices, Story, SessionFlowName, Voice, VoiceType, StoryTranslation +from chatbot.models.geo_models import ProfileAddress +from chatbot.utils.story_llama_utils import translate_field, create_project +from chatbot.utils.story_utils.format_utils import clean_escaped_text, get_formatted_story +from chatbot.utils.transliterate_utils import transliterate_text, get_transliteration_output +from shikshalokam.models import Project, Task +from shikshalokam.serializer import TaskSerializer + +logger = logging.getLogger('django') + + +def is_english_text(text): + """Check if text contains only English characters (a-z, A-Z, numbers, punctuation, spaces)""" + if not text or str(text).strip() == '': + logger.info(f"[ENGLISH CHECK] Received empty or blank text. Treating as English. text='{text}'") + return True + + original_text = str(text) + logger.info(f"[ENGLISH CHECK] Starting English validation. text='{original_text}'") + + # Remove common punctuation and numbers + cleaned_text = re.sub( + r'[0-9\s\.,\!\?\-\(\)\[\]\{\}\"\'\:\;\@\#\$\%\^\&\*\+\=\_\|\\\/<>~`]', + '', + original_text + ) + logger.info(f"[ENGLISH CHECK] Cleaned text after removing numbers & punctuation: '{cleaned_text}'") + + is_english = bool(re.match(r'^[a-zA-Z]*$', cleaned_text)) + + if is_english: + logger.info(f"[ENGLISH CHECK] Text identified as English. text='{original_text}', cleaned='{cleaned_text}'") + else: + logger.info(f"[ENGLISH CHECK] Non-English characters detected. text='{original_text}', cleaned='{cleaned_text}'") + + return is_english + + + +def translate_to_english_if_needed(text, voice_provider, source_language): + """Translate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to translate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to translate. The data {text} is already in english.") + return text + + try: + if voice_provider: + translated = translate_field( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language + ) + logger.info(f"Translated data to english: {translated}.") + return translated + else: + logger.info(f"No voice provider available for translation. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error translating to English: {e}") + return text + + +def transliterate_to_english_if_needed(text, voice_provider, source_language): + """Transliterate text to English if it's not already in English""" + if not text or text.strip() == '': + logger.info(f"No need to transliterate. The data {text} is empty.") + return text + + if is_english_text(text): + logger.info(f"No need to transliterate. The data {text} is already in english.") + return text + + try: + if voice_provider: + is_sentence = ' ' in text + transliterated = transliterate_text( + voice_provider=voice_provider, + message_body=text, + target_language='en', + source_language=source_language, + is_sentence=is_sentence + ) + logger.info(f"Transliterated data to english: {transliterated}.") + return get_transliteration_output(data=transliterated) + else: + logger.info(f"No voice provider available for transliteration. Keeping original text: {text}") + return text + except Exception as e: + logger.error(f"Error transliterating to English: {e}") + return text + + +def save_story( + response_json_story, language, voice_provider, profile, session, combined_reason, flow=None, project_id=None, + company_bot=None +): + try: + # Get voice providers for translation/transliteration + translation_voice_provider = voice_provider + transliteration_voice_provider = None + + if company_bot and language != 'en': + transliteration_voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + + # Extract and translate/transliterate fields to English + raw_title = response_json_story.get('title', '') + raw_content = response_json_story.get('content', '') + raw_objective = response_json_story.get('objective', '') + raw_impact = response_json_story.get('impact', '') + raw_problem_statement = response_json_story.get('problem_statement', '') + raw_blurb = response_json_story.get('blurb', '') + + is_within_domain = response_json_story.get('is_within_domain', True) + + if not is_within_domain: + raise StoryDomainError() + + # Translate main content fields to English + english_title = clean_escaped_text( + text=translate_to_english_if_needed(raw_title, translation_voice_provider, language) + ) + english_content = clean_escaped_text( + text=translate_to_english_if_needed(raw_content, translation_voice_provider, language) + ) + english_objective = clean_escaped_text( + text=translate_to_english_if_needed(raw_objective, translation_voice_provider, language) + ) + english_impact = clean_escaped_text( + text=translate_to_english_if_needed(raw_impact, translation_voice_provider, language) + ) + english_problem_statement = clean_escaped_text( + text=translate_to_english_if_needed(raw_problem_statement, translation_voice_provider, language) + ) + english_blurb = clean_escaped_text( + text=translate_to_english_if_needed(raw_blurb, translation_voice_provider, language) + ) + + # Handle action_steps (can be string or list) + raw_action_steps = response_json_story.get('action_steps', []) + if isinstance(raw_action_steps, str): + english_action_steps = translate_to_english_if_needed(raw_action_steps, translation_voice_provider, + language) + else: + english_action_steps = [ + translate_to_english_if_needed(step, translation_voice_provider, language) + for step in raw_action_steps + ] + + english_tweet = clean_escaped_text( + text=translate_to_english_if_needed(response_json_story.get('tweet', ''), translation_voice_provider, + language) + ) + english_micro_improvement = clean_escaped_text( + text=translate_to_english_if_needed(response_json_story.get('micro_improvement', ''), + translation_voice_provider, language) + ) + duration = clean_escaped_text( + text=translate_to_english_if_needed(response_json_story.get('duration', ''), translation_voice_provider, + language) + ) + + if flow and flow in [SessionFlowName.GuestMiStory] and not project_id: + # Transliterate personal information fields for guest stories + raw_user_name = response_json_story.get('user_name', '') + raw_location = response_json_story.get('location', '') + raw_organization = response_json_story.get('organization', '') + raw_designation = response_json_story.get('designation', '') + + user_name = transliterate_to_english_if_needed(raw_user_name, transliteration_voice_provider, language) + location = transliterate_to_english_if_needed(raw_location, transliteration_voice_provider, language) + organization = transliterate_to_english_if_needed(raw_organization, transliteration_voice_provider, + language) + designation = transliterate_to_english_if_needed(raw_designation, transliteration_voice_provider, language) + else: + user_name = profile.first_name if profile and profile.first_name else '' + organization = response_json_story.get('organization', '') + designation = response_json_story.get('designation', '') + location = None + if profile: + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + location = ", ".join(location_parts) + else: + location = "" + + if (not english_title or not english_objective or not english_action_steps or not + english_problem_statement or not english_content or not english_blurb + ): + raise StoryValidationError() + + if flow in [SessionFlowName.Reflection] and project_id: + logger.info(f"project_id: %s", project_id) + project = Project.objects.filter(project_id=project_id).first() + if project: + tasks = Task.objects.filter(project=project) + serialized_tasks = TaskSerializer(tasks, many=True).data + english_action_steps = [f"{idx + 1}. {task.get('task_name')}" for idx, task in + enumerate(serialized_tasks)] + + other_params = { + 'duration': duration, + 'flow': flow, + 'user_name': user_name, + } + + if flow and flow in [SessionFlowName.GuestMiStory]: + other_params['user_name'] = user_name + other_params['location'] = location + other_params['organization'] = organization + other_params['designation'] = designation + + story = Story.objects.filter(session=session).first() + if story: + story.title = english_title + story.content = english_content + story.tweet = english_tweet + story.author = profile + story.objective = english_objective + story.action_steps = english_action_steps + story.impact = english_impact + story.micro_improvement = english_micro_improvement + story.language = 'en' + story.stage = StoryStatusChoices.COMPLETED + story.other_params = other_params + story.location = location if location else "" + story.blurb = english_blurb + story.validation_logs = combined_reason + else: + story = Story( + title=english_title, + content=english_content, + tweet=english_tweet, + author=profile, + session=session, + objective=english_objective, + action_steps=english_action_steps, + impact=english_impact, + micro_improvement=english_micro_improvement, + language='en', + stage=StoryStatusChoices.COMPLETED, + other_params=other_params, + location=location if location else "", + blurb=english_blurb, + validation_logs=combined_reason + ) + story.save() + + if language != 'en': + create_story_translation( + story=story, + language=language, + english_data={ + 'title': english_title, + 'content': english_content, + 'tweet': english_tweet, + 'objective': english_objective, + 'action_steps': english_action_steps, + 'impact': english_impact, + 'micro_improvement': english_micro_improvement, + 'blurb': english_blurb + }, + voice_provider=voice_provider, + flow=flow, + company_bot=company_bot, + other_data={ + 'user_name': user_name, + 'organization': organization, + 'designation': designation, + 'location': location + } + ) + + create_project( + response_json=response_json_story, title=english_title, objective=english_objective, story=story, + profile=profile, problem_statement=english_problem_statement, language=language, + voice_provider=voice_provider, project_id=project_id, action_steps=english_action_steps + ) + + return story, english_problem_statement + + except StoryError: + raise + + except Exception as e: + logger.error('Error Occurred: %s', e, exc_info=True) + traceback.print_exc() + raise StorySaveError() + + +def create_story_translation(story, language, english_data, voice_provider, flow, company_bot, other_data): + """Create translation for a story""" + try: + translated_title = translate_field( + voice_provider=voice_provider, message_body=english_data['title'], target_language=language + ) + translated_content = translate_field( + voice_provider=voice_provider, message_body=english_data['content'], target_language=language + ) + translated_tweet = translate_field( + voice_provider=voice_provider, message_body=english_data['tweet'], target_language=language + ) + translated_objective = translate_field( + voice_provider=voice_provider, message_body=english_data['objective'], target_language=language + ) + translated_impact = translate_field( + voice_provider=voice_provider, message_body=english_data['impact'], target_language=language + ) + translated_micro_improvement = translate_field( + voice_provider=voice_provider, message_body=english_data['micro_improvement'], target_language=language + ) + translated_blurb = translate_field( + voice_provider=voice_provider, message_body=english_data['blurb'], target_language=language + ) + + # action_steps (can be string or list) + action_steps = english_data['action_steps'] + if isinstance(action_steps, str): + translated_action_steps = translate_field( + voice_provider=voice_provider, message_body=action_steps, target_language=language + ) + else: + translated_action_steps = [ + translate_field( + voice_provider=voice_provider, + message_body=action_step, + target_language=language + ) + for action_step in action_steps + ] + + import copy + translated_other_params = copy.deepcopy(story.other_params) if story.other_params else {} + + if translated_other_params.get('duration'): + duration_value = translated_other_params['duration'] + if ' ' in str(duration_value): + translated_other_params['duration'] = translate_field( + voice_provider=voice_provider, + message_body=duration_value, + target_language=language + ) + + if flow and flow in [SessionFlowName.GuestMiStory] and company_bot: + voice_transliterate_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.Transliterate, language=language + ).first() + transliterate_fields = ['user_name', 'organization', 'designation', 'location'] + + for field_name in transliterate_fields: + field_value = other_data.get(field_name, '') + if field_value and field_value != '': + is_sentence = ' ' in field_value + transliterated = transliterate_text( + voice_provider=voice_transliterate_provider, + message_body=field_value, + target_language=language, + source_language='en', + is_sentence=is_sentence + ) + translated_other_params[field_name] = get_transliteration_output(data=transliterated) + + translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=language, + defaults={ + 'title': translated_title, + 'content': translated_content, + 'tweet': translated_tweet, + 'objective': translated_objective, + 'action_steps': translated_action_steps, + 'impact': translated_impact, + 'micro_improvement': translated_micro_improvement, + 'blurb': translated_blurb, + 'other_params': translated_other_params, + 'formatted_content': '' + } + ) + + if not created: + translation.title = translated_title + translation.content = translated_content + translation.tweet = translated_tweet + translation.objective = translated_objective + translation.action_steps = translated_action_steps + translation.impact = translated_impact + translation.micro_improvement = translated_micro_improvement + translation.blurb = translated_blurb + translation.other_params = translated_other_params + translation.save() + + formatted_translation_content = get_formatted_story(translation) + if formatted_translation_content: + translation.formatted_content = formatted_translation_content + translation.save(update_fields=['formatted_content']) + + logger.info(f"Created/Updated translation for story {story.id} in language {language}") + return translation + + except Exception as e: + logger.error(f'Error creating translation: %s', e, exc_info=True) + return None + + +def get_story_in_language(story, language='en'): + """Get story content in specified language""" + if language == 'en' or language == story.language: + return { + 'title': story.title, + 'content': story.content, + 'tweet': story.tweet, + 'objective': story.objective, + 'action_steps': story.action_steps, + 'impact': story.impact, + 'micro_improvement': story.micro_improvement, + 'blurb': story.blurb, + 'other_params': story.other_params + } + + try: + translation = story.translations.get(language=language) + return { + 'title': translation.title, + 'content': translation.content, + 'tweet': translation.tweet, + 'objective': translation.objective, + 'action_steps': translation.action_steps, + 'impact': translation.impact, + 'micro_improvement': translation.micro_improvement, + 'blurb': translation.blurb, + 'other_params': story.other_params, + 'translated_other_params': translation.translated_other_params # Translated parts + } + except StoryTranslation.DoesNotExist: + return get_story_in_language(story, 'en') diff --git a/chatbot/utils/story_utils/ptm/ptm_story_tasks.py b/chatbot/utils/story_utils/ptm/ptm_story_tasks.py new file mode 100644 index 0000000..c42c517 --- /dev/null +++ b/chatbot/utils/story_utils/ptm/ptm_story_tasks.py @@ -0,0 +1,119 @@ +import traceback +import logging +from chatbot.models import StoryStatusChoices, Story, StoryTranslation +from chatbot.utils.story_llama_utils import translate_field + +logger = logging.getLogger('django') + + +def save_ptm_story( + response_json_story, language, voice_provider, profile, session, combined_reason, flow=None, + company_bot=None +): + try: + name = response_json_story.get("name", "") + district = response_json_story.get("district", "") + school = response_json_story.get("school", "") + role = response_json_story.get("role", "") + ptm_experience_summary = response_json_story.get("ptm_experience_summary", "") + key_highlights = response_json_story.get("key_highlights", "") + perceived_changes_or_impact = response_json_story.get("perceived_changes_or_impact", "") + + other_params = { + "user_name": name, + "district": district, + "school": school, + "role": role, + "ptm_experience_summary": ptm_experience_summary, + "key_highlights": key_highlights, + "perceived_changes_or_impact": perceived_changes_or_impact, + "flow": flow, + } + + english_title = f"{name}'s PTM Reflection" if name and name != '' else "PTM Reflection" + + story = Story.objects.filter(session=session).first() + if story: + story.title = english_title + story.language = 'en' # Always English + story.stage = StoryStatusChoices.COMPLETED + story.other_params = other_params + story.validation_logs = combined_reason + else: + story = Story( + title=english_title, + author=profile, + session=session, + language='en', # Always English + stage=StoryStatusChoices.COMPLETED, + other_params=other_params, + validation_logs=combined_reason + ) + story.save() + + if language != 'en': + create_ptm_translation( + story=story, + language=language, + english_title=english_title, + voice_provider=voice_provider, + company_bot=company_bot, + ptm_data={ + 'ptm_experience_summary': ptm_experience_summary, + 'key_highlights': key_highlights, + 'perceived_changes_or_impact': perceived_changes_or_impact, + 'name': name, + 'district': district, + 'school': school, + 'role': role + } + ) + + return story, ptm_experience_summary + except Exception as e: + logger.error("Error in save_ptm_story: %s", e, exc_info=True) + traceback.print_exc() + raise Exception("Failed to save PTM story") + + +def create_ptm_translation(story, language, english_title, voice_provider, company_bot, ptm_data): + """Create translation for PTM story""" + try: + # Note: In the original code, PTM translation was commented out + # You can uncomment and modify this based on your needs + + translated_title = translate_field( + voice_provider=voice_provider, message_body=english_title, target_language=language + ) + + # Uncomment if you want to translate PTM fields: + # translated_other_params = {} + # for field in ['ptm_experience_summary', 'key_highlights', 'perceived_changes_or_impact']: + # if ptm_data.get(field): + # translated_other_params[field] = translate_field( + # voice_provider=voice_provider, + # message_body=ptm_data[field], + # target_language=language + # ) + + # Create or update translation + translation, created = StoryTranslation.objects.get_or_create( + story=story, + language=language, + defaults={ + 'title': translated_title, + 'content': '', + # 'translated_other_params': translated_other_params # Uncomment if needed + } + ) + + if not created: + translation.title = translated_title + # translation.translated_other_params = translated_other_params # Uncomment if needed + translation.save() + + return translation + + except Exception as e: + logger.error(f'Error creating PTM translation: %s', e, exc_info=True) + return None diff --git a/chatbot/utils/story_utils/story_llm.py b/chatbot/utils/story_utils/story_llm.py new file mode 100644 index 0000000..02d3510 --- /dev/null +++ b/chatbot/utils/story_utils/story_llm.py @@ -0,0 +1,201 @@ +import asyncio +import functools +import json_repair +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import LLMProvider, SessionFlowName +import logging + + +logger = logging.getLogger('django') + + +async def generate_story_llm(formatted_content_prompt, formatted_story_prompt, messages, tool_content, tool_story, company_bot): + async def invoke_llm(prompt, tools): + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=prompt, + messages=messages, + tools=tools, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + elif company_bot.provider == LLMProvider.OPENAI: + return await asyncio.to_thread( + functools.partial( + handle_openai_model, + system_prompt=prompt, + messages=messages, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model + ) + ) + + tasks = [] + + if formatted_content_prompt: + tasks.append(invoke_llm(formatted_content_prompt, tool_content)) + else: + logger.info("Skipping CONTENT LLM as formatted_content_prompt is None") + + if formatted_story_prompt: + tasks.append(invoke_llm(formatted_story_prompt, tool_story)) + else: + logger.info("Skipping STORY LLM as formatted_story_prompt is None") + + results = await asyncio.gather(*tasks) + + response_json_content = results[0] if formatted_content_prompt else None + response_json_story = results[1] if (formatted_content_prompt and formatted_story_prompt) else ( + results[0] if (not formatted_content_prompt and formatted_story_prompt) else None + ) + + logger.info(f"response_json_content: %s", response_json_content) + logger.info(f"response_json_story: %s", response_json_story) + + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + for response in [response_json_content, response_json_story]: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + logger.info(f"Final response_json_content: %s", response_json_content) + logger.info(f"Final response_json_story: %s", response_json_story) + + + return response_json_content, response_json_story + + +async def validate_story_llm(formatted_content_prompt, formatted_story_prompt, messages, tool_content, tool_story, + company_bot, flow): + async def func1(): + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_content_prompt, + messages=messages, + tools=tool_content, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + elif company_bot.provider == LLMProvider.OPENAI: + return await asyncio.to_thread( + functools.partial( + handle_openai_model, + system_prompt=formatted_content_prompt, + messages=messages, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model + ) + ) + + async def func2(): + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_story_prompt, + messages=messages, + tools=tool_story, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + elif company_bot.provider == LLMProvider.OPENAI: + return await asyncio.to_thread( + functools.partial( + handle_openai_model, + system_prompt=formatted_story_prompt, + messages=messages, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model + ) + ) + + tasks = [] + + if formatted_content_prompt: + tasks.append(func1()) + else: + logger.info("Skipping CONTENT LLM as formatted_content_prompt is None") + + if formatted_story_prompt: + tasks.append(func2()) + else: + logger.info("Skipping STORY LLM as formatted_story_prompt is None") + + results = await asyncio.gather(*tasks) + + response_json_content = results[0] if formatted_content_prompt else None + response_json_story = results[1] if (formatted_content_prompt and formatted_story_prompt) else ( + results[0] if (not formatted_content_prompt and formatted_story_prompt) else None + ) + + logger.info(f"Validation: response_json_content: %s", response_json_content) + logger.info(f"Validation: response_json_story: %s", response_json_story) + if company_bot.provider == LLMProvider.BEDROCK_CONVERSE: + for response in [response_json_content, response_json_story]: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + reason_content="" + reason_content = response_json_content.get('reason') + response_json_content = response_json_content.get('final_answer') + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + reason_story="" + if response_json_story: + reason_story = response_json_story.get('reason') + response_json_story = response_json_story.get('final_answer') + if response_json_story and isinstance(response_json_story, str): + response_json_story = json_repair.repair_json(response_json_story, return_objects=True) + + logger.info(f"Final Validation: response_json_content: %s", response_json_content) + + if (isinstance(response_json_story, dict) and response_json_story.get("type") and + "value" in response_json_story): + value = response_json_story.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_story = value + + if (isinstance(response_json_content, dict) and response_json_content.get("type") and + "value" in response_json_content): + value = response_json_content.get("value") + if isinstance(value, str) and value.strip(): + value = json_repair.repair_json(value, return_objects=True) + response_json_content = value + + combined_result = {**(response_json_content or {}), **(response_json_story or {})} + + combined_reason = { + "reason_content": reason_content, + "reason_story": reason_story + } + + return combined_result, combined_reason diff --git a/chatbot/utils/story_utils/story_utils.py b/chatbot/utils/story_utils/story_utils.py new file mode 100644 index 0000000..21b1e33 --- /dev/null +++ b/chatbot/utils/story_utils/story_utils.py @@ -0,0 +1,422 @@ +from pygments.lexer import combined + +from chatbot.models import (Profile, CompanyChat, ChatSession, ChatStatus, Voice, VoiceType, SessionFlowName, BotVernacular, StoryTranslation, CompanyBot) +from chatbot.models.company_models import Flow +from chatbot.serializer.profile_serializer import ProfileSerializer +from chatbot.utils.chat_utils import get_guided_chat +from chatbot.utils.shikshalokam_mitra_utils import get_stored_conversation, get_stored_chathistory +from chatbot.utils.shikshalokam_story_utils import save_shikshalokam_story, save_project_story +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.story_utils.common.generic_story_tasks import save_generic_story +from chatbot.utils.story_utils.get_story_prompts import get_creation_promt, get_tool_values, get_validation_prompt +from chatbot.utils.story_utils.story_llm import generate_story_llm, validate_story_llm +from rest_framework.exceptions import NotFound + +from chatbot.utils.story_utils.chaupal.chaupal_story_tasks import save_chaupal_report +from chatbot.utils.story_utils.format_utils import get_formatted_story +from chatbot.utils.story_utils.mi_story_capture.mi_story_tasks import save_story +from chatbot.utils.story_utils.ptm.ptm_story_tasks import save_ptm_story + +import asyncio +import logging +import traceback +from pprint import pprint + +logger = logging.getLogger('django') + + +def create_story_object(profile_id, session, access_token, flow, language='en'): + voice_provider = None + company_bot = None + print("Working with flow: ", flow) + try: + profile = Profile.objects.filter(id=profile_id).first() + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + + company_bot, validate_bot = get_story_company_bot(profile=profile, flow=flow) + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + chat_session = ChatSession.objects.get(session=session) + + formatted_content_prompt, formatted_story_prompt, tag_context, project_data = get_creation_promt( + company_bot=company_bot, profile=profile + ) + + pprint({ + "formatted_content_prompt": formatted_content_prompt, + "formatted_story_prompt": formatted_story_prompt, + "tag_context": tag_context, + "project_data": project_data, + }) + + intro_to_pass = None + + if flow in [SessionFlowName.GuestMiStory, SessionFlowName.GuestDiscussion]: + route_to_use = None + if flow == SessionFlowName.GuestMiStory: + route_to_use = '/guided_guest' + elif flow == SessionFlowName.GuestDiscussion: + route_to_use = '/shikshalokam_chaupal' + if route_to_use: + flow_company_bot = CompanyBot.objects.get(company=profile.company, route=route_to_use) + bot_vernacular = BotVernacular.objects.filter(company_bot=flow_company_bot).first() + if bot_vernacular: + if access_token: + intro_to_pass = bot_vernacular.introductory_message + if profile and profile.first_name and intro_to_pass: + words = intro_to_pass.split(" ", 1) + if len(words) > 1: + intro_to_pass = f"{words[0]} {profile.first_name} {words[1]}" + else: + intro_to_pass = f"{words[0]} {profile.first_name}" + else: + intro_to_pass = bot_vernacular.alt_introductory_message + # Handle intro for new flows (common flow) + else: + try: + session_company_bot = chat_session.company_bot + if session_company_bot: + bot_vernacular = BotVernacular.objects.filter(company_bot=session_company_bot).first() + if bot_vernacular: + if access_token: + intro_to_pass = bot_vernacular.introductory_message + if profile and profile.first_name and intro_to_pass: + words = intro_to_pass.split(" ", 1) + if len(words) > 1: + intro_to_pass = f"{words[0]} {profile.first_name} {words[1]}" + else: + intro_to_pass = f"{words[0]} {profile.first_name}" + else: + intro_to_pass = bot_vernacular.alt_introductory_message + except Exception as e: + logger.warning(f"Could not get intro for new flow {flow}: {e}") + + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats, intro=intro_to_pass + ) + + tool_content, tool_story = get_tool_values(company_bot=company_bot) + + response_json_content, response_json_story = asyncio.run( + generate_story_llm( + formatted_content_prompt=formatted_content_prompt, formatted_story_prompt=formatted_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=company_bot, + ) + ) + + logger.info(f"STORY response_json_content: %s", response_json_content) + logger.info(f"STORY response_json_story: %s", response_json_story) + + validate_content_prompt, validate_story_prompt = get_validation_prompt( + response_json_story=response_json_story, validate_bot=validate_bot, + response_json_content=response_json_content, tag_context=tag_context, project_data=project_data, + profile=profile + ) + + tool_content, tool_story = get_tool_values(company_bot=validate_bot) + + if company_bot.provider != validate_bot.provider: + messages = get_guided_chat( + company_bot=validate_bot, company_chats=company_chats, intro=intro_to_pass + ) + + response_json_story, combined_reason = asyncio.run( + validate_story_llm( + formatted_content_prompt=validate_content_prompt, formatted_story_prompt=validate_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=validate_bot, + flow=flow + ) + ) + + logger.info(f"VALIDATION STORY response_json_story: %s", response_json_story) + + # Save story based on flow type + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.SsoFlow, SessionFlowName.GuestMiStory, + SessionFlowName.Reflection]: + story, problem_statement = save_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + project_id=chat_session.project_id, company_bot=company_bot + ) + elif flow == SessionFlowName.megaPTM: + story, problem_statement = save_ptm_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + company_bot=company_bot + ) + elif flow == SessionFlowName.GuestDiscussion: + story, problem_statement = save_chaupal_report( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + messages=messages, company_bot=company_bot + ) + else: + story, problem_statement = save_generic_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile, session=session, combined_reason=combined_reason, flow=flow, + project_id=chat_session.project_id, company_bot=company_bot + ) + + if story: + formatted_content = get_formatted_story(story) + if formatted_content: + story.formatted_content = formatted_content + story.save(update_fields=['formatted_content']) + + if language != 'en': + try: + translation = story.translations.get(language=language) + formatted_translation_content = get_formatted_story(translation) + if formatted_translation_content: + translation.formatted_content = formatted_translation_content + translation.save(update_fields=['formatted_content']) + except StoryTranslation.DoesNotExist: + pass + + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save(update_fields=['session_status']) + chat_session.save_title(language=language) + + if flow == SessionFlowName.Reflection: + conversation = get_stored_conversation(company_chats=company_chats) + chat_history = get_stored_chathistory(company_chats=company_chats) + else: + conversation, chat_history = [], [] + + save_shikshalokam_story( + story=story, profile=profile, + problem_statement=problem_statement, chat_history=chat_history, access_token=access_token, + project_id=None, session=session, conversation=conversation, flow=flow + ) + + story_id = story.id if story and story.id else "" + story_content = story.content if story and story.content else "" + + return story_id, story_content, "", "" + + except Exception as e: + traceback.print_exc() + + error_type = getattr(e, "code", "generic_error") + + if not company_bot: + profile = Profile.objects.filter(id=profile_id).first() + company_bot, validate_bot = get_story_company_bot(profile=profile, flow=flow) + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = get_bot_error_message(bot_vernacular, error_type) + if voice_provider and language != 'en': + error_message = translate_field( + voice_provider=voice_provider, message_body=error_message, target_language=language + ) + return "", "", error_message, error_type + + +def get_bot_error_message(bot_vernacular, error_type): + + if not bot_vernacular or not bot_vernacular.error_message: + return "Please try again!" + + raw = bot_vernacular.error_message + + if isinstance(raw, dict): + data = raw + else: + try: + import json + data = json.loads(raw) + except Exception: + return raw + + return data.get(error_type) or data.get("generic_error") or "Please try again!" + + +def generate_story(profile_id, session, access_token, flow, language='en'): + voice_provider = None + company_bot = None + print("Working with flow: ", flow) + try: + profile = Profile.objects.prefetch_related('profile_address').defer('password').get(id=profile_id) + + profile_data = ProfileSerializer(profile).data + company_chats = CompanyChat.objects.select_related('sender', 'receiver').filter(session=session).order_by('created_at').values("receiver", "receiver__id", "translated_message", "message", "status", "created_at") + + company_bot, validate_bot = get_story_company_bot_simple(flow=flow) + + voice_provider = Voice.objects.filter(company_bot=company_bot, type=VoiceType.TextToText, language=language).first() + + chat_session = ChatSession.objects.get(session=session) + + formatted_content_prompt, formatted_story_prompt, tag_context, project_data = get_creation_promt(company_bot=company_bot, profile=profile_data) + + intro_to_pass = None + + try: + session_company_bot = chat_session.company_bot + if session_company_bot: + bot_vernacular = BotVernacular.objects.filter(company_bot=session_company_bot).first() + if bot_vernacular: + if access_token: + intro_to_pass = bot_vernacular.introductory_message + if profile_data and profile_data.get("first_name") and intro_to_pass: + words = intro_to_pass.split(" ", 1) + if len(words) > 1: + intro_to_pass = f"{words[0]} {profile_data.get('first_name')} {words[1]}" + else: + intro_to_pass = f"{words[0]} {profile_data.get('first_name')}" + else: + intro_to_pass = bot_vernacular.alt_introductory_message + else: + raise ValueError("No bot found in the chat session") + except Exception as e: + traceback.print_exc() + logger.warning(f"Could not get intro for new flow {flow}: {e}") + + messages = get_guided_chat( + company_bot=company_bot, company_chats=company_chats, intro=intro_to_pass + ) + + tool_content, tool_story = get_tool_values(company_bot=company_bot) + + response_json_content, response_json_story = asyncio.run( + generate_story_llm( + formatted_content_prompt=formatted_content_prompt, formatted_story_prompt=formatted_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=company_bot + ) + ) + + logger.info(f"STORY response_json_content: %s", response_json_content) + logger.info(f"STORY response_json_story: %s", response_json_story) + + combined_reason = None + if validate_bot: + validate_content_prompt, validate_story_prompt = get_validation_prompt( + response_json_story=response_json_story, validate_bot=validate_bot, + response_json_content=response_json_content, tag_context=tag_context, project_data=project_data, + profile=profile_data + ) + + tool_content, tool_story = get_tool_values(company_bot=validate_bot) + + if company_bot.provider != validate_bot.provider: + messages = get_guided_chat(company_bot=validate_bot, company_chats=company_chats, intro=intro_to_pass) + + response_json_story, combined_reason = asyncio.run( + validate_story_llm( + formatted_content_prompt=validate_content_prompt, formatted_story_prompt=validate_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=validate_bot, + flow=flow + ) + ) + + logger.info("VALIDATION STORY response_json_story: {story}".format(story=response_json_story)) + + story, problem_statement = save_generic_story( + response_json_story=response_json_story, language=language, voice_provider=voice_provider, + profile=profile_data, session=session, combined_reason=combined_reason, flow=flow, + company_bot=company_bot + ) + + if story: + formatted_content = get_formatted_story(story) + if formatted_content: + story.formatted_content = formatted_content + story.save(update_fields=['formatted_content']) + + if language != 'en': + try: + translation = story.translations.get(language=language) + formatted_translation_content = get_formatted_story(translation) + if formatted_translation_content: + translation.formatted_content = formatted_translation_content + translation.save(update_fields=['formatted_content']) + except StoryTranslation.DoesNotExist: + pass + + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save(update_fields=['session_status']) + chat_session.save_title(language=language) + + if flow == SessionFlowName.Reflection: + conversation = get_stored_conversation(company_chats=company_chats) + chat_history = get_stored_chathistory(company_chats=company_chats) + else: + conversation, chat_history = [], [] + + save_project_story( + story=story, profile=profile_data, + problem_statement=problem_statement, chat_history=chat_history, access_token=access_token, + project_id=None, session=session, conversation=conversation, flow=flow + ) + + story_id = story.id if story and story.id else "" + story_content = story.content if story and story.content else "" + + return story_id, story_content, "", "" + + except Exception as e: + traceback.print_exc() + error_type = getattr(e, "code", "generic_error") + + if not company_bot: + profile = Profile.objects.filter(id=profile_id).first() + company_bot, validate_bot = get_story_company_bot_simple(flow=flow) + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = get_bot_error_message(bot_vernacular, error_type) + + if voice_provider and language != 'en': + error_message = translate_field( + voice_provider=voice_provider, message_body=error_message, target_language=language + ) + return "", "", error_message, error_type + + +def get_story_company_bot_simple(flow): + try: + company_flow = Flow.objects.get(flow_route=flow) + + company_story_bot = company_flow.story_bot + company_story_validation_bot = company_flow.story_validation_bot + + if not company_story_bot: + raise NotFound(detail=f"Story bot not configured for the flow: {flow}") + + return company_story_bot, company_story_validation_bot + + except Flow.DoesNotExist: + logger.error(f"Flow not found for route: {flow}") + raise NotFound(detail=f"Flow not found with route: {flow}") + + + +def get_story_company_bot(profile, flow): + if flow in [SessionFlowName.LoginMiStory, SessionFlowName.Reflection, SessionFlowName.SsoFlow]: + company_bot = CompanyBot.objects.get(route='/story') + validate_bot = CompanyBot.objects.get(route='/story_validation') + elif flow in [SessionFlowName.GuestMiStory]: + company_bot = CompanyBot.objects.get(route='/guest-story') + validate_bot = CompanyBot.objects.get(route='/guest-story_validation') + elif flow in [SessionFlowName.megaPTM]: + company_bot = CompanyBot.objects.get(route='/ptm-story') + validate_bot = CompanyBot.objects.get(route='/ptm-story_validation') + elif flow == SessionFlowName.GuestDiscussion: + company_bot = CompanyBot.objects.get(route='/chaupal-story') + validate_bot = CompanyBot.objects.get(route='/chaupal-_validation') + else: + flow_name = flow.value if hasattr(flow, 'value') else str(flow) + story_route = f'/{flow_name}-story' + validation_route = f'/{flow_name}-story_validation' + + try: + company_bot = CompanyBot.objects.get(route=story_route) + validate_bot = CompanyBot.objects.get(route=validation_route) + except CompanyBot.DoesNotExist: + logger.error(f"CompanyBot not found for routes: {story_route}, {validation_route}") + company_bot = CompanyBot.objects.get(route='/story') + validate_bot = CompanyBot.objects.get(route='/story_validation') + + return company_bot, validate_bot \ No newline at end of file diff --git a/chatbot/utils/story_utils_test.py b/chatbot/utils/story_utils_test.py new file mode 100644 index 0000000..9a4325b --- /dev/null +++ b/chatbot/utils/story_utils_test.py @@ -0,0 +1,453 @@ +import json +import traceback +import random +import string +from chatbot.models import (Profile, CompanyChat, CompanyBot, StoryLanguageChoices, + StoryStatusChoices, ChatSession, ChatStatus, Voice, VoiceType, BotVernacular, + SessionFlowName) +from chatbot.models.geo_models import ProfileAddress +from chatbot.models.story_models import Story +from chatbot.utils.shikshalokam_mitra_utils import get_stored_conversation, get_stored_chathistory +from chatbot.utils.shikshalokam_story_utils import save_shikshalokam_story +from chatbot.utils.story_llama_utils import create_project, translate_field +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.utils.story_utils.story_llm import generate_story_llm +from shikshalokam.models import Project, Task +from shikshalokam.serializer import TaskSerializer +from shikshalokam.utils.project_utils import get_project_formatted_data +from jinja2 import Template +import json_repair +import asyncio +import functools + + +def create_story_object(profile_id, session, access_token, flow, language='en'): + error_message = "" + voice_provider=None + try: + profile = Profile.objects.get(id=profile_id) + company_chats = CompanyChat.objects.filter(session=session).order_by('created_at') + ai_user = Profile.objects.get(id=1) + company_bot = CompanyBot.objects.get(route='/story') + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + if bot_vernacular: + error_message = bot_vernacular.error_message + else: + error_message = "Please try again!" + + voice_provider = Voice.objects.filter(company_bot=company_bot, type=VoiceType.TextToText).first() + reflection_bot = CompanyBot.objects.filter(route='/reflection').first() + validate_bot = CompanyBot.objects.get(route='/story_validation') + context = company_bot.context + address = ProfileAddress.objects.filter(profile=profile) + context_data = { + "profile": profile, + "address": address if address else [{}] + } + template = Template(company_bot.tag_context) + + tag_context = template.render(context_data) + + end_context = company_bot.end_context + + chat_session = ChatSession.objects.get(session=session) + project_id = chat_session.project_id + + if flow == SessionFlowName.Reflection and project_id and reflection_bot: + reflection_end_context = reflection_bot.end_context + user_project = Project.objects.filter(project_id=project_id).first() + project_data = get_project_formatted_data(user_project=user_project) + project_data = reflection_end_context.format(**project_data) + print("project_data: ", project_data) + if language != 'en': + project_data = translate_field( + voice_provider=voice_provider, message_body=project_data, + source_language=language, target_language='en' + ) + print("translated project_data: ", project_data) + else: + project_data = '' + + content_prompt = f""" + {context} + {tag_context} + {project_data} + """ + story_prompt = f""" + {end_context} + {tag_context} + {project_data} + """ + print('-------------------------------') + print(story_prompt) + + messages=[] + formatted_content_prompt = [ + { + 'text': content_prompt + }, + ] + formatted_story_prompt = [ + { + 'text': story_prompt + }, + ] + if company_chats and company_chats[0].receiver != ai_user: + company_chats.pop(0) + for chat in company_chats: + user_message = chat.message + if chat.receiver == ai_user: + if chat.translated_message is not None and chat.translated_message != '': + user_message = chat.translated_message + messages.append({ + 'role': 'user', + 'content': [{'text': user_message}] + }) + else: + messages.append({ + 'role': 'assistant', + 'content': [{'text': user_message}] + }) + + # print("Message: ", messages) + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + tool_story = tool_context.get('story_tool') + tool_content = tool_context.get('content_tool') + print("\n----------") + response_json_content, response_json_story = asyncio.run( + generate_story_llm( + formatted_content_prompt=formatted_content_prompt, formatted_story_prompt=formatted_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=company_bot + ) + ) + print("\n\nresponse_json_content: ", response_json_content) + print("\n\nresponse_json_story: ", response_json_story) + validate_context_data = { + "story_json_output": response_json_story, + } + validate_template = Template(validate_bot.tag_context) + validate_tag_context = validate_template.render(validate_context_data) + + validate_story_prompt = f""" + {validate_bot.end_context} + {validate_tag_context} + {tag_context} + {project_data} + """ + + validate_context_data = { + "story_json_output": response_json_content, + } + validate_tag_context = validate_template.render(validate_context_data) + + validate_content_prompt = f""" + {validate_bot.context} + {validate_tag_context} + {tag_context} + {project_data} + """ + + validate_content_prompt = [ + { + 'text': validate_content_prompt + }, + ] + validate_story_prompt = [ + { + 'text': validate_story_prompt + }, + ] + + tool_context = validate_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + tool_story = tool_context.get('story_tool') + tool_content = tool_context.get('content_tool') + print("------------------------------------------") + print("\n\nvalidate_content_prompt: ", validate_content_prompt) + print("\n\nvalidate_story_prompt: ", validate_story_prompt) + print("------------------------------------------") + response_json_story, combined_reason = asyncio.run( + validate_story_llm( + formatted_content_prompt=validate_content_prompt, formatted_story_prompt=validate_story_prompt, + messages=messages, tool_content=tool_content, tool_story=tool_story, company_bot=validate_bot + ) + ) + print("\n\nvalidated_result: ", response_json_story) + print("\n\ntype validated_result: ", type(response_json_story)) + print("\n\ncombined_reason: ", combined_reason) + print("\n----------") + + title = response_json_story.get('title', '') + print('title: ', title) + tweet = response_json_story.get('tweet', '') + print('tweet: ', tweet) + objective = response_json_story.get('objective', '') + print('objective: ', objective) + action_steps = response_json_story.get('action_steps', '') + print('action_steps: ', action_steps) + impact = response_json_story.get('impact', '') + print('impact: ', impact) + micro_improvement = response_json_story.get('micro_improvement', '') + print('micro_improvement: ', micro_improvement) + problem_statement = response_json_story.get('problem_statement', '') + print('problem_statement: ', problem_statement) + + duration = response_json_story.get('duration', '') + other_params = { + 'duration': duration + } + + content = response_json_story.get('content', '') + print('content: ', content) + blurb = response_json_story.get('blurb', '') + print('blurb: ', blurb) + content = clean_escaped_text(text=content) + print('clean content: ', content) + + print("language used: ", language) + if language != 'en': + title = translate_field( + voice_provider=voice_provider, message_body=title, target_language=language + ) + tweet = translate_field( + voice_provider=voice_provider, message_body=tweet, target_language=language + ) + objective = translate_field( + voice_provider=voice_provider, message_body=objective, target_language=language + ) + action_steps = translate_field( + voice_provider=voice_provider, message_body=action_steps, target_language=language + ) + impact = translate_field( + voice_provider=voice_provider, message_body=impact, target_language=language + ) + micro_improvement = translate_field( + voice_provider=voice_provider, message_body=micro_improvement, target_language=language + ) + problem_statement = translate_field( + voice_provider=voice_provider, message_body=problem_statement, target_language=language + ) + content = translate_field( + voice_provider=voice_provider, message_body=content, target_language=language + ) + blurb = translate_field( + voice_provider=voice_provider, message_body=blurb, target_language=language + ) + if flow == SessionFlowName.Reflection: + print("project_id: ", project_id) + project = Project.objects.get(project_id=project_id) + if project: + print("project: ", project) + tasks = Task.objects.filter(project=project) + serialized_tasks = TaskSerializer(tasks, many=True).data + print("tasks serialized_tasks: ", serialized_tasks) + # action_steps = [task.get('task_name') for task in serialized_tasks] + action_steps = [f"{idx + 1}. {task.get('task_name')}" for idx, task in enumerate(serialized_tasks)] + print("tasks action_steps: ", action_steps) + + if profile: + address = ProfileAddress.objects.filter(profile=profile).first() + if address: + location_parts = filter(None, [address.block, address.district, address.state]) + location = ", ".join(location_parts) + else: + location = "" + else: + location = "" + + story = Story.objects.filter(session=session).first() + if story: + story.title = title + story.content = content + story.tweet = tweet + story.author = profile + story.objective = objective + story.action_steps = action_steps + story.impact = impact + story.micro_improvement = micro_improvement + story.language = StoryLanguageChoices.ENGLISH + story.stage = StoryStatusChoices.COMPLETED + story.other_params = other_params + story.location = location + story.blurb = blurb + story.validation_logs = combined_reason + else: + story = Story( + title=title, + content=content, + tweet=tweet, + author=profile, + session=session, + objective=objective, + action_steps=action_steps, + impact=impact, + micro_improvement=micro_improvement, + language=StoryLanguageChoices.ENGLISH, + stage=StoryStatusChoices.COMPLETED, + other_params=other_params, + location=location, + blurb=blurb, + validation_logs=combined_reason + ) + + story.save() + formatted_content = get_formatted_story(story) + story.formatted_content = formatted_content + story.save(update_fields=['formatted_content']) + + create_project( + response_json=response_json_story,title=title, objective=objective, story=story, + profile=profile, problem_statement=problem_statement, project_id=project_id, language=language, + voice_provider=voice_provider + ) + + chat_session.session_status = ChatStatus.COMPLETED + chat_session.save(update_fields=['session_status']) + chat_session.save_title(language=language) + conversation = get_stored_conversation(company_chats=company_chats) + chat_history = get_stored_chathistory(company_chats=company_chats) + + save_shikshalokam_story( + story=story, access_token=access_token, + problem_statement=problem_statement, project_id=project_id, session=session, + profile=profile, conversation=conversation, flow=flow, chat_history=chat_history + ) + + return story.id, story.content, "" + + except Exception as e: + print("Error msg in except: ", error_message) + print("voice_provider in except: ", voice_provider) + if voice_provider and error_message and language != 'en': + error_message=translate_field( + voice_provider=voice_provider, message_body=error_message, target_language=language + ) + traceback.print_exc() + return "", "", error_message + + +def format_response_json(response): + response_json = response.replace('\n', '').replace('\t', '').replace( + '\r', '').replace('\\n', '').replace('\\t', '').replace('\\r', '') + if '{' in response_json: + response_json = response_json[response_json.index('{'):] + last_char = response_json[-1] + if last_char != '}': + response_json += '}' + print("\nBEFORE LOADS: ", response_json) + if isinstance(response_json, str): + response_json = json_repair.repair_json(response_json, return_objects=True) + print("AFTER LOADS: ", response_json) + print("TYPE response_json: ", type(response_json)) + + return response_json + + +def get_formatted_story(story): + story_paragraphs = story.content.split("\n") + res = [] + for paragraph in story_paragraphs: + res.append( + { + 'id': generate_random_string(10), + 'type': 'paragraph', + 'data': + { + 'text': paragraph, + } + } + ) + return json.dumps(res) + + +def generate_random_string(length): + characters = string.ascii_letters + string.digits + rs = ''.join(random.choice(characters) for _ in range(length)) + return rs + + +async def validate_story_llm(formatted_content_prompt, formatted_story_prompt, messages, tool_content, tool_story, + company_bot): + async def func1(): + print("Running func1") + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_content_prompt, + messages=messages, + tools=tool_content, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + + async def func2(): + print("Running func2") + return await asyncio.to_thread( + functools.partial( + handle_bedrock_model, + system_prompt=formatted_story_prompt, + messages=messages, + tools=tool_story, + temperature=company_bot.bot_temperature, + max_token=company_bot.max_token, + top_p=company_bot.filter_score, + model_name=company_bot.llm_model, + company_bot=company_bot + ) + ) + + response_json_content, response_json_story = await asyncio.gather(func1(), func2()) + print("response_json_content: ", response_json_content) + print("response_json_story: ", response_json_story) + for response in [response_json_content, response_json_story]: + if response and isinstance(response, dict): + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response.clear() + response.update(extracted_data) + + reason_content = response_json_content.get('reason') + response_json_content = response_json_content.get('final_answer') + if response_json_content and isinstance(response_json_content, str): + response_json_content = json_repair.repair_json(response_json_content, return_objects=True) + + reason_story = response_json_story.get('reason') + response_json_story = response_json_story.get('final_answer') + if response_json_story and isinstance(response_json_story, str): + response_json_story = json_repair.repair_json(response_json_story, return_objects=True) + + print("response_json_content: ", response_json_content) + print("response_json_story: ", response_json_story) + + if (isinstance(response_json_story, dict) and response_json_story.get("type") == "string" and + "value" in response_json_story): + value = response_json_story.get("value") + if isinstance(value, str) and value.strip(): + response_json_story = json_repair.repair_json(value, return_objects=True) + + if (isinstance(response_json_content, dict) and response_json_content.get("type") == "string" and + "value" in response_json_content): + value = response_json_content.get("value") + if isinstance(value, str) and value.strip(): + response_json_content = json_repair.repair_json(value, return_objects=True) + + combined_result = {**response_json_content, **response_json_story} + combined_reason = { + "reason_content": reason_content, + "reason_story": reason_story + } + + return combined_result, combined_reason + + +def clean_escaped_text(text): + text = text.replace("\\'", "")# \' → ' + text = text.replace('\\"', '')# \" → " + text = text.replace("\\\\", "") # \\ → \ + print("Text: ", text) + return text diff --git a/chatbot/utils/transliterate_utils.py b/chatbot/utils/transliterate_utils.py new file mode 100644 index 0000000..bb94692 --- /dev/null +++ b/chatbot/utils/transliterate_utils.py @@ -0,0 +1,56 @@ +from chatbot.models import VoiceProvider, VoiceType, LanguageMapping, CompanyBot +from chatbot.translate.ai4Bharat.transliterate import call_ai4bharat_transliterate_api +from chatbot.translate.custom.custom_llm import handle_custom_translation +from chatbot.translate.sarvam.sarvam import SarvamLanguageService +from chatbot.utils.audio_provider_utils import get_voice_provider + + +def transliterate_text( + source_language, target_language, message_body, is_sentence=False, voice_provider=None, company_bot=None +): + try: + if not voice_provider and company_bot: + voice_provider = get_voice_provider( + company_bot=company_bot, voice_type=VoiceType.Transliterate, source_language=source_language, + target_language=target_language + ) + if voice_provider.provider == VoiceProvider.AI4Bharat: + response = call_ai4bharat_transliterate_api( + source_language=source_language, target_language=target_language, message_body=message_body, + is_sentence=is_sentence + ) + elif voice_provider.provider == VoiceProvider.SARVAM: + service = SarvamLanguageService() + response = service.transliterate( + input_text=message_body, source_lang=LanguageMapping.get_mapped_language(source_language), + target_lang=LanguageMapping.get_mapped_language(target_language), + voice_provider=voice_provider + ) + elif voice_provider.provider == VoiceProvider.CUSTOM_LLM: + other = getattr(voice_provider, "other_params", {}) or {} + route = other.get('route', "/transliterate_text") + company_bot = CompanyBot.objects.filter(route=route).first() + response = handle_custom_translation( + message_body=message_body, source_language=LanguageMapping.get_mapped_language(source_language), + target_language=LanguageMapping.get_mapped_language(target_language), company_bot=company_bot + ) + else: + return { + 'status': 500, + 'content': "No provider found!" + } + return response + except Exception as e: + return { + 'status': 500, + 'content': message_body + } + + +def get_transliteration_output(data): + if data and isinstance(data, dict): + data = data.get('content', []) + if data and isinstance(data, list) and len(data) > 0: + return data[0] + + return None diff --git a/chatbot/views/Media/document_upload_view.py b/chatbot/views/Media/document_upload_view.py new file mode 100644 index 0000000..e03fa47 --- /dev/null +++ b/chatbot/views/Media/document_upload_view.py @@ -0,0 +1,302 @@ +import json +import traceback +from django.http import JsonResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from chatbot.models import Media, KeyValue, CompanyBot, Company, FileTypeChoices, FileDisplayMode +from shikshalokam.models.enums import PriorityChoices +from django.core.files.base import ContentFile + + +@method_decorator(csrf_exempt, name='dispatch') +class DocumentUploadView(View): + """ + API endpoint for uploading documents to the knowledge base + POST /api/v1/documents + """ + + def post(self, request): + """ + Handle document upload with multipart/form-data + + Required Fields: + - file: File to upload (PDF, TXT, DOCX, MD, etc.) + + Optional Fields: + - priority: string (default: "P1") - "P1", "P2", "P3" + - source_id: string - External source identifier + - company_id: string - Company slug/identifier + - title: string - Document title + - summary: string - Document summary + - metadata: string (JSON) - Additional metadata as JSON string + - tags: string (JSON array OR CSV) - Tags as JSON array or comma-separated + """ + try: + # Validate file upload + if 'file' not in request.FILES: + return JsonResponse({ + 'success': False, + 'error': 'No file provided', + 'message': 'File is required' + }, status=400) + + uploaded_file = request.FILES['file'] + + # Validate file type + file_extension = uploaded_file.name.split('.')[-1].lower() if '.' in uploaded_file.name else None + if file_extension and not FileTypeChoices.is_valid_extension(file_extension): + return JsonResponse({ + 'success': False, + 'error': 'Invalid file type', + 'message': f'File type .{file_extension} is not supported' + }, status=400) + + # Extract optional fields + priority = request.POST.get('priority', PriorityChoices.P1) + source_id = request.POST.get('source_id', None) + company_id = request.POST.get('company_id', None) + title = request.POST.get('title', None) + summary = request.POST.get('summary', None) + metadata_str = request.POST.get('metadata', None) + tags_str = request.POST.get('tags', None) + + # Validate priority + valid_priorities = [choice[0] for choice in PriorityChoices.choices] + if priority not in valid_priorities: + return JsonResponse({ + 'success': False, + 'error': 'Invalid priority', + 'message': f'Priority must be one of: {", ".join(valid_priorities)}' + }, status=400) + + # Parse metadata (JSON string) + metadata = {} + if metadata_str: + try: + metadata = json.loads(metadata_str) + if not isinstance(metadata, dict): + return JsonResponse({ + 'success': False, + 'error': 'Invalid metadata format', + 'message': 'Metadata must be a valid JSON object' + }, status=400) + except json.JSONDecodeError as e: + return JsonResponse({ + 'success': False, + 'error': 'Invalid metadata JSON', + 'message': f'Failed to parse metadata: {str(e)}' + }, status=400) + + # Parse tags (JSON array OR CSV) + tags = [] + if tags_str: + try: + # Try parsing as JSON array first + parsed_tags = json.loads(tags_str) + if isinstance(parsed_tags, list): + tags = [str(tag).strip() for tag in parsed_tags if tag] + else: + return JsonResponse({ + 'success': False, + 'error': 'Invalid tags format', + 'message': 'Tags must be a JSON array or comma-separated string' + }, status=400) + except json.JSONDecodeError: + # Fallback to CSV parsing + tags = [tag.strip() for tag in tags_str.split(',') if tag.strip()] + + # Get or validate company + company = None + if company_id: + try: + company = Company.objects.get(slug=company_id) + except Company.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Company not found', + 'message': f'Company with ID "{company_id}" does not exist' + }, status=404) + + # Get default company bot (you may need to adjust this logic based on your requirements) + # For now, we'll get the first available company bot for the company + company_bot = None + if company: + company_bot = CompanyBot.objects.filter(company=company).first() + + if not company_bot: + # Fallback to any available company bot + company_bot = CompanyBot.objects.first() + + if not company_bot: + return JsonResponse({ + 'success': False, + 'error': 'No company bot available', + 'message': 'Please configure a company bot first' + }, status=400) + + # Determine media type from file extension + media_type = FileTypeChoices.TXT # default + if file_extension: + mime_type = FileTypeChoices.get_mime_from_extension(file_extension) + if mime_type: + media_type = mime_type + + # Use title or filename as the media name + media_name = title if title else uploaded_file.name + + # Create Media object + media = Media( + name=media_name, + media_type=media_type, + priority=priority, + description=summary, + company_bot=company_bot, + organization=company, + display_mode=FileDisplayMode.VISIBLE + ) + + # Save the uploaded file + file_content = uploaded_file.read() + media.file.save(uploaded_file.name, ContentFile(file_content), save=False) + + # Save media and trigger vector DB save + company_slug = company.slug if company else (company_bot.company.slug if company_bot.company else None) + vector_task_id = media.save(company_slug=company_slug) + + # Save source_id as key-value if provided + if source_id: + KeyValue.objects.create( + media=media, + key='SOURCE_ID', + value=source_id + ) + + # Save title as key-value if provided + if title: + KeyValue.objects.create( + media=media, + key='TITLE', + value=title + ) + + # Save metadata as key-value pairs + for key, value in metadata.items(): + KeyValue.objects.create( + media=media, + key=key.upper(), + value=str(value) + ) + + # Save tags + if tags: + from chatbot.models import Tag + tag_objects = [] + for tag_name in tags: + tag, created = Tag.objects.get_or_create( + name=tag_name, + defaults={'company': company or company_bot.company} + ) + tag_objects.append(tag) + media.tags.set(tag_objects) + + # Prepare response + response_data = { + 'success': True, + 'message': 'Document uploaded successfully', + 'data': { + 'media_id': media.id, + 'name': media.name, + 'media_type': media.media_type, + 'priority': media.priority, + 'source_id': source_id, + 'company_id': company_slug, + 'title': title, + 'summary': summary, + 'tags': tags, + 'metadata': metadata, + 'vector_task_id': vector_task_id, + 'created_at': media.created_at.isoformat() if media.created_at else None, + 'file_url': media.get_s3_url() if hasattr(media, 'get_s3_url') else None + } + } + + return JsonResponse(response_data, status=201) + + except Exception as e: + print(f"Error uploading document: {e}") + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': 'Internal server error', + 'message': str(e) + }, status=500) + + def get(self, request): + """Return API documentation""" + return JsonResponse({ + 'endpoint': 'POST /api/v1/documents', + 'description': 'Upload a document to the knowledge base', + 'request_type': 'multipart/form-data', + 'required_fields': { + 'file': { + 'type': 'File', + 'description': 'File to upload (PDF, TXT, DOCX, MD, etc.)' + } + }, + 'optional_fields': { + 'priority': { + 'type': 'string', + 'default': 'P1', + 'format': 'Plain text', + 'example': 'P1, P2, P3' + }, + 'source_id': { + 'type': 'string', + 'default': None, + 'format': 'Plain text', + 'example': 'doc_12345' + }, + 'company_id': { + 'type': 'string', + 'default': None, + 'format': 'Plain text', + 'example': 'company_abc' + }, + 'title': { + 'type': 'string', + 'default': None, + 'format': 'Plain text', + 'example': 'Q4 Financial Report' + }, + 'summary': { + 'type': 'string', + 'default': None, + 'format': 'Plain text', + 'example': 'Summary of Q4 results' + }, + 'metadata': { + 'type': 'string', + 'default': None, + 'format': 'JSON string', + 'example': '{"author": "John", "department": "Finance"}' + }, + 'tags': { + 'type': 'string', + 'default': None, + 'format': 'JSON array OR CSV', + 'example': '["finance", "report"] OR "finance, report"' + } + }, + 'example_curl': ''' +curl -X POST "http://your-api.com/api/v1/documents" \\ + -F "file=@/path/to/document.pdf" \\ + -F "priority=P1" \\ + -F "source_id=doc_12345" \\ + -F "company_id=company_abc" \\ + -F "title=Q4 Financial Report" \\ + -F "summary=Quarterly financial summary" \\ + -F 'metadata={"author": "John", "department": "Finance"}' \\ + -F 'tags=["finance", "report", "Q4"]' + ''' + }) diff --git a/chatbot/views/Media/extract_views.py b/chatbot/views/Media/extract_views.py new file mode 100644 index 0000000..1dfb30d --- /dev/null +++ b/chatbot/views/Media/extract_views.py @@ -0,0 +1,300 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse +from django.views import View +from chatbot.models import Profile, FileTypeChoices, CompanyBot +import json +import tempfile +import uuid +from chatbot.celery_tasks.knowledge_service.tag_tasks import get_auto_extracted_data +from chatbot.utils.knowledge_service.auto_tag_utils import TagProcessor +from chatbot.utils.knowledge_service.cache_manager import CacheManager + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaExtractView(View): + """API endpoint for extracting data from uploaded files""" + + def post(self, request): + try: + import re + import time + + files = request.FILES.getlist('files') + company_bot_id = request.POST.get('company_bot_id') + session_id = request.POST.get('session_id') + + # Get file indices if provided + file_indices = request.POST.getlist('file_indices') + + extracted_data = [] + + # Generate session ID if not provided + if not session_id: + session_id = str(uuid.uuid4()) + + company_bot = None + if company_bot_id: + try: + company_bot = CompanyBot.objects.get(id=company_bot_id) + except CompanyBot.DoesNotExist: + pass + + print(f"Processing {len(files)} files with indices: {file_indices}") + + for i, file in enumerate(files): + try: + # Use provided file index or generate unique one + if i < len(file_indices) and file_indices[i]: + file_index = int(file_indices[i]) + else: + # Generate unique index if not provided + file_index = int(time.time() * 1000000) + i + + print(f"Processing file {i}: {file.name} with index {file_index}") + + # Store file for retry purposes with sanitized cache key + file_key = CacheManager.cache_file(file, session_id, file_index) + + data = self.extract_file_data( + file=file, + company_bot=company_bot, + file_index=file_index, # Use unique index + request=request + ) + if data.get('error') or data.get('error_type'): + raise Exception(data.get('error', 'AI extraction failed')) + + data['status'] = 'success' + data['error'] = None + data['session_id'] = session_id + data['file_key'] = file_key + + print(f"Successfully processed file {file.name}, cache key: {file_key}") + + except Exception as e: + print(f"Error processing file {file.name}: {e}") + + # For failed extractions, still cache the file + if i < len(file_indices) and file_indices[i]: + file_index = int(file_indices[i]) + else: + file_index = int(time.time() * 1000000) + i + + file_key = CacheManager.cache_file(file, session_id, file_index) + + data = { + 'filename': file.name, + 'status': 'error', + 'error': str(e), + 'file_index': file_index, + 'session_id': session_id, + 'file_key': file_key, + 'name': file.name, + 'media_type': self.get_media_type(file.name), + 'description': f'Extracted from {file.name}', + 'extracted_text': '', + 'priority': 'P1', + 'tags': [], + 'manual_tags': [], + 'auto_tags': [], + 'auto_tag_task_id': None, + 'auto_tags_ready': True, + 'key_values': [], + 'subdocument': [], + 'images': [] + } + + extracted_data.append(data) + + print(f"Completed processing {len(extracted_data)} files") + + return JsonResponse({ + 'success': True, + 'data': extracted_data, + 'session_id': session_id + }) + + except Exception as e: + print(f"BatchMediaExtractView.post() error: {e}") + import traceback + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=400) + + def extract_file_data(self, file, company_bot, file_index, request=None): + """Extract data from file and start async AI extraction""" + file_extension = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else None + + if file_extension and not FileTypeChoices.is_valid_extension(file_extension): + raise ValueError(f"Unsupported file format: .{file_extension}") + + max_file_size_mb = 50 + if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: + try: + other_params = json.loads(company_bot.other_params) if isinstance( + company_bot.other_params, str + ) else company_bot.other_params + max_file_size_mb = other_params.get('max_file_size_mb', 50) + except: + pass + + max_file_size_bytes = max_file_size_mb * 1024 * 1024 + + if file.size > max_file_size_bytes: + file_size_mb = file.size / (1024 * 1024) + raise ValueError( + f"File size ({file_size_mb:.2f} MB) exceeds the maximum allowed size of {max_file_size_mb} MB. " + f"Please reduce the file size.") + + # Save file temporarily + with tempfile.NamedTemporaryFile(delete=False, suffix=f".{file_extension}") as tmp: + for chunk in file.chunks(): + tmp.write(chunk) + tmp_path = tmp.name + + user_profile = None + company = None + company_name = '' + if request and request.user.is_authenticated: + try: + user_profile = Profile.objects.get(email=request.user.email) + company = user_profile.company + if company: + company_name = company.name + except Profile.DoesNotExist: + pass + + master_tags = TagProcessor.get_master_tags( + company=company, other_params=company_bot.other_params if company_bot else None + ) + print("Sending master tags: ", master_tags) + base_name = file.name.rsplit('.', 1)[0] if '.' in file.name else file.name + + other_data = { + "master_tag": master_tags, + "original_filename": base_name + } + + # Start async task (non-blocking) + print(f"Starting async extraction task for {file.name}") + task = get_auto_extracted_data.delay( + file_path=tmp_path, + company_bot_id=company_bot.id if company_bot else None, + file_extension=file_extension, + other_data=other_data + ) + base_name = file.name.rsplit('.', 1)[0] if '.' in file.name else file.name + + return { + 'filename': file.name, + 'file_index': file_index, + 'name': base_name, + 'media_type': self.get_media_type(file.name), + 'description': f'Extracted from {file.name}', + 'extracted_text': '', + 'priority': 'P1', + 'tags': [], + 'manual_tags': [], + 'auto_tags': [], + 'auto_tag_task_id': task.id, + 'auto_tags_ready': False, + 'key_values': [], + 'subdocument': [], + 'images': [], + 'failed_links': [], + 'organization': company_name, + 'company_name': company_name + } + + def get_media_type(self, filename): + """Map file extension to media type using FileTypeChoices""" + ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else None + return FileTypeChoices.get_mime_from_extension(ext) if ext else FileTypeChoices.TXT.value + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaRetryExtractView(View): + """API endpoint for retrying extraction of a single file""" + + def post(self, request): + try: + data = json.loads(request.body) + file_data = data.get('file_data') + company_bot_id = data.get('company_bot_id') + session_id = data.get('session_id') + + if not file_data: + return JsonResponse({ + 'success': False, + 'error': 'No file data provided' + }, status=400) + + company_bot = None + if company_bot_id: + try: + company_bot = CompanyBot.objects.get(id=company_bot_id) + except CompanyBot.DoesNotExist: + pass + + # Try to retrieve stored file + file_key = file_data.get('file_key') + stored_file = None + + if file_key: + stored_file = CacheManager.get_cached_item(file_key) + + if not stored_file: + return JsonResponse({ + 'success': False, + 'error': 'Original file data not found. Please re-upload the file or try uploading again.' + }, status=400) + + # Create a file-like object from stored data + class StoredFile: + def __init__(self, stored_data): + self.name = stored_data['name'] + self.size = stored_data['size'] + self._content = stored_data['content'] + + def chunks(self): + chunk_size = 8192 + for i in range(0, len(self._content), chunk_size): + yield self._content[i:i + chunk_size] + + try: + stored_file_obj = StoredFile(stored_file) + extract_view = BatchMediaExtractView() + extracted_data = extract_view.extract_file_data( + file=stored_file_obj, + company_bot=company_bot, + file_index=file_data.get('file_index', 0), + request=request + ) + extracted_data['status'] = 'success' + extracted_data['error'] = None + extracted_data['session_id'] = session_id + extracted_data['file_key'] = file_key + + return JsonResponse({ + 'success': True, + 'data': extracted_data + }) + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': str(e) + }) + + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Invalid JSON data' + }, status=400) + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }, status=400) diff --git a/chatbot/views/Media/google_drive_integration.py b/chatbot/views/Media/google_drive_integration.py new file mode 100644 index 0000000..e7789f1 --- /dev/null +++ b/chatbot/views/Media/google_drive_integration.py @@ -0,0 +1,410 @@ +import os +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' +import io +import json +import re +import tempfile +import uuid +import time + +from django.core.files.base import ContentFile +from django.http import JsonResponse, FileResponse +from django.shortcuts import redirect +from django.views.generic import TemplateView +from django.views import View +from django.conf import settings +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseDownload +from googleapiclient.errors import HttpError + +from chatbot.models import Company, CompanyBot, FileDisplayMode, FileTypeChoices, KeyValue, Media +from shikshalokam.models.enums import PriorityChoices + +# Import the native LLM extraction tools +from chatbot.celery_tasks.knowledge_service.tag_tasks import get_auto_extracted_data +from chatbot.utils.knowledge_service.cache_manager import CacheManager +from chatbot.utils.knowledge_service.auto_tag_utils import TagProcessor + +GOOGLE_DRIVE_SCOPES = ['https://www.googleapis.com/auth/drive.readonly'] +GOOGLE_EXPORT_MIME_TYPES = { + "application/vnd.google-apps.document": "application/pdf", + "application/vnd.google-apps.spreadsheet": "text/csv", + "application/vnd.google-apps.presentation": "application/pdf", +} + + +def get_client_secret_path(): + paths_to_try = [ + os.path.join(getattr(settings, 'CODE_BASE_DIR', ''), 'client_secret.json'), + os.path.join(settings.BASE_DIR, 'client_secret.json'), + ] + for path in paths_to_try: + if path and os.path.exists(path): + return path + return paths_to_try[0] + + +def get_redirect_uri(request): + if request.path.startswith('/admin/'): + return request.build_absolute_uri('/admin/chatbot/media/google-drive/callback/') + return request.build_absolute_uri('/google-drive/callback/') + + +def get_drive_credentials(request): + credentials_data = request.session.get('google_credentials') + if not credentials_data: + return None + return Credentials(**credentials_data) + + +def get_drive_service(request): + credentials = get_drive_credentials(request) + if not credentials: + return None + return build('drive', 'v3', credentials=credentials) + + +def get_default_extraction_bot(): + return CompanyBot.objects.filter(route='/tag_extractor').first() or CompanyBot.objects.first() + + + +class GoogleDriveIntegrationView(TemplateView): + template_name = 'google_drive_integration.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Pull native collections to match your Batch Upload step1 layout requirements + context['companies'] = Company.objects.all().order_by('name') + context['company_bots'] = CompanyBot.objects.all() + + # Match the normal media upload flow's extraction bot. + default_bot = get_default_extraction_bot() + context['default_bot_id'] = default_bot.id if default_bot else None + + # Check if requesting user has profile matching organization parameters + if hasattr(self.request.user, 'email'): + from chatbot.models import Profile + profile = Profile.objects.filter(email=self.request.user.email).first() + if profile and profile.company: + context['user_company'] = profile.company + + return context + + +class GoogleDriveAuthView(View): + def get(self, request): + client_secret_path = get_client_secret_path() + if not os.path.exists(client_secret_path): + return JsonResponse({ + 'success': False, + 'error': 'client_secret.json not found', + 'message': f'Create {client_secret_path} from client_secret.sample.json' + }, status=500) + + flow = Flow.from_client_secrets_file( + client_secret_path, + scopes=GOOGLE_DRIVE_SCOPES, + redirect_uri=get_redirect_uri(request) + ) + auth_url, state = flow.authorization_url( + prompt='consent', + access_type='offline', + include_granted_scopes='true' + ) + # CRITICAL: Store the state and generated code verifier in the session + request.session['oauth_state'] = state + request.session['oauth_code_verifier'] = flow.code_verifier + + return redirect(auth_url) + + +class GoogleDriveCallbackView(View): + def get(self, request): + client_secret_path = get_client_secret_path() + if not os.path.exists(client_secret_path): + return JsonResponse({ + 'success': False, + 'error': 'client_secret.json not found', + 'message': f'Create {client_secret_path} from client_secret.sample.json' + }, status=500) + + # FIX: Extract state and code_verifier back out of the user's session + state = request.session.get('oauth_state') + code_verifier = request.session.get('oauth_code_verifier') + + flow = Flow.from_client_secrets_file( + client_secret_path, + scopes=GOOGLE_DRIVE_SCOPES, + redirect_uri=get_redirect_uri(request), + state=state # Added 'state' parameter to cross-verify structural security integrity + ) + + # CRITICAL: Pass the saved code_verifier to complete the handshake + flow.fetch_token( + authorization_response=request.build_absolute_uri(), + code_verifier=code_verifier + ) + + credentials = flow.credentials + request.session['google_credentials'] = { + 'token': credentials.token, + 'refresh_token': credentials.refresh_token, + 'token_uri': credentials.token_uri, + 'client_id': credentials.client_id, + 'client_secret': credentials.client_secret, + 'scopes': credentials.scopes + } + + # Clean up the session variables since authentication is complete + request.session.pop('oauth_state', None) + request.session.pop('oauth_code_verifier', None) + + if request.path.startswith('/admin/'): + return redirect('/admin/chatbot/media/google-drive/?connected=1') + return redirect('/google-drive/?connected=1') + + + +def extract_folder_id(folder_url): + match = re.search( + r'/folders/([a-zA-Z0-9_-]+)', + folder_url + ) + return match.group(1) if match else None + + +def get_all_files_in_folder(service, initial_folder_id): + """ + Recursively fetches all files inside a folder and its subfolders. + """ + files_found = [] + folders_to_search = [initial_folder_id] + + while folders_to_search: + # Get the next folder in the queue + current_folder_id = folders_to_search.pop(0) + page_token = None + + while True: + try: + results = service.files().list( + q=f"'{current_folder_id}' in parents and trashed=false", + pageSize=100, + fields="nextPageToken, files(id, name, mimeType, size)", + pageToken=page_token + ).execute() + + # Separate actual files from sub-folders + for item in results.get('files', []): + if item['mimeType'] == 'application/vnd.google-apps.folder': + # Found a nested folder! Add it to the queue to search later + folders_to_search.append(item['id']) + else: + # Found a file! Add it to our final list + files_found.append(item) + + page_token = results.get('nextPageToken') + if not page_token: + break + + except HttpError as error: + # If a nested folder has restricted permissions, skip it and continue + print(f"Skipping inaccessible nested folder {current_folder_id}: {error}") + break + + return files_found + + + +def download_drive_file(service, file_id): + # 1. Ask Google for the permissions metadata alongside the file info + metadata = service.files().get( + fileId=file_id, + fields="id, name, mimeType, permissions" + ).execute() + + # 2. STRICT CHECK: Ensure the file itself is set to "Anyone with the link" + is_public = any(p.get('type') == 'anyone' for p in metadata.get('permissions', [])) + if not is_public: + raise ValueError("not_public") + + mime_type = metadata["mimeType"] + + if mime_type in GOOGLE_EXPORT_MIME_TYPES: + request = service.files().export_media( + fileId=file_id, + mimeType=GOOGLE_EXPORT_MIME_TYPES[mime_type] + ) + else: + request = service.files().get_media( + fileId=file_id + ) + + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + done = False + while done is False: + _, done = downloader.next_chunk() + fh.seek(0) + + return metadata, fh.read() + + +class GoogleDriveFileImportView(View): + def post(self, request): + service = get_drive_service(request) + if not service: + return JsonResponse({'success': False, 'error': 'Google Drive is not connected'}, status=401) + + try: + data = json.loads(request.body or '{}') + except json.JSONDecodeError: + return JsonResponse({'success': False, 'error': 'Invalid JSON body'}, status=400) + + folder_url = data.get('folder_url') + company_id = data.get('company_id') or data.get('organization_slug') + bot_id = data.get('company_bot_id') + + if not folder_url: + return JsonResponse({'success': False, 'error': 'folder_url is required'}, status=400) + + folder_id = extract_folder_id(folder_url) + if not folder_id: + return JsonResponse({'success': False, 'error': 'Invalid folder URL'}, status=400) + + # 1. Resolve organization + company = Company.objects.filter(slug=company_id).first() if company_id else None + company_bot = CompanyBot.objects.filter(id=bot_id).first() if bot_id else None + if company and company_bot and company_bot.company_id != company.id: + return JsonResponse( + {'success': False, 'error': 'company_bot_mismatch'}, + status=400 + ) + if company_bot and not company: + company = company_bot.company + if not company_bot and company: + company_bot = CompanyBot.objects.filter(company=company, route='/tag_extractor').first() + company_bot = company_bot or get_default_extraction_bot() + + if not company_bot: + return JsonResponse({'success': False, 'error': 'No company bot available'}, status=400) + + # 2. Check strict permissions on root folder + try: + folder_meta = service.files().get(fileId=folder_id, fields="permissions").execute() + is_public = any(p.get('type') == 'anyone' for p in folder_meta.get('permissions', [])) + if not is_public: + return JsonResponse({'success': False, 'error': 'not_public'}, status=403) + except HttpError: + return JsonResponse({'success': False, 'error': 'not_public'}, status=403) + + # 3. Recursively Fetch ALL Files + all_files = get_all_files_in_folder(service, folder_id) + if not all_files: + return JsonResponse({'success': False, 'error': 'empty_folder'}, status=400) + + session_id = str(uuid.uuid4()) + extracted_data = [] + + # Dummy file wrapper for CacheManager compatibility + class DummyFile: + def __init__(self, name, size, content): + self.name = name + self.size = size + self._content = content + def chunks(self): + chunk_size = 8192 + for i in range(0, len(self._content), chunk_size): + yield self._content[i:i + chunk_size] + def read(self): + return self._content + + # 4. Download, Cache, and Trigger LLM Extraction sequentially + for i, file_info in enumerate(all_files): + file_id = file_info['id'] + original_name = file_info['name'] + + try: + metadata, content = download_drive_file(service, file_id) + extension = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else 'pdf' + + if extension not in ['pdf', 'csv', 'txt', 'docx', 'xlsx', 'doc', 'xls']: + extension = 'pdf' + + with tempfile.NamedTemporaryFile(delete=False, suffix=f".{extension}") as tmp: + tmp.write(content) + tmp_path = tmp.name + + dummy_file = DummyFile(original_name, len(content), content) + file_index = int(time.time() * 1000000) + i + file_key = CacheManager.cache_file(dummy_file, session_id, file_index) + if not file_key: + raise RuntimeError("cache_file_failed") + master_tags = TagProcessor.get_master_tags(company=company, other_params=company_bot.other_params if company_bot else None) + other_data = {"master_tag": master_tags, "original_filename": original_name} + + # TRIGGER CELERY LLM EXTRACTION TASK + task = get_auto_extracted_data.delay( + file_path=tmp_path, + company_bot_id=company_bot.id, + file_extension=extension, + other_data=other_data + ) + + base_name = original_name.rsplit('.', 1)[0] if '.' in original_name else original_name + actual_media_type = FileTypeChoices.get_mime_from_extension(extension) or FileTypeChoices.TXT.value + + extracted_data.append({ + 'filename': original_name, + 'file_index': file_index, + 'name': base_name, + 'media_type': actual_media_type, + 'description': f'Extracted from {original_name}', + 'priority': 'P1', + 'tags': [], + 'manual_tags': [], + 'auto_tags': [], + 'auto_tag_task_id': task.id, + 'auto_tags_ready': False, + 'key_values': [], + 'subdocument': [], + 'images': [], + 'failed_links': [], + 'organization': company.name if company else '', + 'organization_slug': company.slug if company else '', + 'file_key': file_key, + 'status': 'success', + 'session_id': session_id + }) + except Exception as e: + print(f"Skipped file {original_name}: {e}") + pass + + if not extracted_data: + return JsonResponse({'success': False, 'error': 'All files were private or corrupted.'}, status=400) + + # 5. Return extraction tasks to frontend + return JsonResponse({ + 'success': True, + 'data': extracted_data, + 'session_id': session_id, + 'company_bot_id': company_bot.id + }) +class GoogleDriveFileDownloadView(View): + def get(self, request, file_id): + service = get_drive_service(request) + if not service: + return JsonResponse({ + 'success': False, + 'error': 'Google Drive is not connected' + }, status=401) + + metadata, content = download_drive_file(service, file_id) + return FileResponse( + io.BytesIO(content), + as_attachment=True, + filename=metadata.get('name') or f'{file_id}.pdf' + ) \ No newline at end of file diff --git a/chatbot/views/Media/media_api_views.py b/chatbot/views/Media/media_api_views.py new file mode 100644 index 0000000..aaf76c8 --- /dev/null +++ b/chatbot/views/Media/media_api_views.py @@ -0,0 +1,1174 @@ +import json_repair +from rest_framework import viewsets, filters, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.views import APIView +from django_filters.rest_framework import DjangoFilterBackend +from django.db import models + +from chatbot.models import Tag, FileTypeChoices, FileDisplayMode, TagChoices, TagSourceChoices +from chatbot.models.media_models import Media, KeyValue +from chatbot.serializer.media_serializer import ( + MediaListSerializer, MediaDetailSerializer, MediaSearchResultSerializer +) +from chatbot.filter.media_filters import MediaFilter +from chatbot.utils.chat_query_handler import query_database_with_metadata +from django.contrib.postgres.search import TrigramSimilarity +from django.db.models import ( + Count, Q, Value, FloatField, OuterRef, Subquery, TextField, + CharField, IntegerField, Case, When, F +) +from django.db.models.functions import Greatest, Coalesce, Lower + + +class FetchThemeView(APIView): + def get(self, request): + filters = { + 'source_type__in': [TagSourceChoices.MANUAL, TagSourceChoices.AI_EXTRACTED], + 'status': TagChoices.APPROVED, + } + is_theme_param = request.query_params.get('is_theme') + if is_theme_param is not None: + filters['is_theme'] = is_theme_param.lower() in ['1', 'true', 'yes'] + + tags = ( + Tag.objects + .filter(**filters) + .annotate(resource_count=Count('medias', distinct=True)) + .order_by('name') + ) + + themes = [ + { + 'title': tag.name, + 'icon': tag.icon or '', + 'description': tag.description or '', + 'resource_count': tag.resource_count + } + for tag in tags + ] + + return Response({ + 'success': True, + 'count': len(themes), + 'themes': themes + }) + + +class MediaViewSet(viewsets.ReadOnlyModelViewSet): + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter + ] + filterset_class = MediaFilter + ordering_fields = [ + 'id', 'name', 'created_at', 'updated_at', 'priority', + 'media_type', 'organization', 'title' + ] + ordering = ['-created_at'] + search_fields = ['name', 'description', 'extracted_text'] + + def filter_queryset(self, queryset): + # Skip OrderingFilter when search is active + search_text = self.request.query_params.get('q', '').strip() + + if search_text and len(search_text) >= 3: + for backend in self.filter_backends: + if backend != filters.OrderingFilter: + queryset = backend().filter_queryset( + self.request, queryset, self + ) + return queryset + else: + return super().filter_queryset(queryset) + + def get_queryset(self): + # Only show visible media + queryset = Media.objects.filter( + display_mode=FileDisplayMode.VISIBLE + ) + + title_subquery = KeyValue.objects.filter( + media=OuterRef('pk'), + key__iexact='TITLE' + ).values('value')[:1] + + queryset = queryset.annotate( + title=Subquery(title_subquery, output_field=CharField()), + organization_name=Coalesce( + 'organization__name', + Value('', output_field=CharField()) + ), + media_type_display=Case( + *[ + When(media_type=choice[0], then=Value(str(choice[1]))) + for choice in FileTypeChoices.choices + ], + default=Value(''), + output_field=CharField() + ) + ) + + source_child_qs = Media.objects.filter( + parent=OuterRef('pk'), + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains='source document' + ).order_by('id') + + child_media_type_sq = Subquery( + source_child_qs.values('media_type')[:1] + ) + + queryset = queryset.annotate( + overridden_media_type=Coalesce(child_media_type_sq, F("media_type")) + ) + + queryset = queryset.annotate( + overridden_media_type_display=Case( + *[ + When(overridden_media_type=choice[0], then=Value(str(choice[1]))) + for choice in FileTypeChoices.choices + ], + default=Value(""), + output_field=CharField() + ) + ) + + search_text = self.request.query_params.get('q', '').strip() + similarity_threshold = float( + self.request.query_params.get('similarity_threshold', 0.3) + ) + + if search_text and len(search_text) >= 3: + # Search mode: apply ranking + queryset = self._apply_enhanced_multi_keyword_search( + queryset, search_text, similarity_threshold + ) + queryset = self._apply_custom_filters(queryset) + else: + # Non-search mode: add default annotations + queryset = queryset.annotate( + keyword_coverage=Value(0, output_field=IntegerField()), + total_matching_fields=Value( + 0, output_field=IntegerField() + ), + avg_relevance_score=Value( + 0.0, output_field=FloatField() + ), + max_similarity=Value(0.0, output_field=FloatField()), + exact_title_match_flag=Value( + 0, output_field=IntegerField() + ), + trigram_match=Value(0, output_field=IntegerField()), + icontains_match=Value(0, output_field=IntegerField()) + ) + queryset = self._apply_custom_filters(queryset) + + # Optimize queries based on action + if self.action == 'list': + queryset = self._apply_content_exclusion_filter(queryset) + queryset = queryset.select_related( + 'organization', 'parent' + ).prefetch_related('tags') + elif self.action == 'retrieve': + queryset = queryset.select_related( + 'organization', 'parent' + ).prefetch_related( + 'tags', 'key_values', 'images', 'subdocuments' + ) + + return queryset.distinct() + + def _apply_content_exclusion_filter(self, queryset): + # Exclude "Source Document" media + source_document_media = KeyValue.objects.annotate( + norm_key=Lower('key', output_field=TextField()) + ).filter( + norm_key__iregex=r'^document[ _]type$', + value__icontains='source document' + ).values_list('media_id', flat=True) + + return queryset.exclude(id__in=source_document_media) + + def _apply_enhanced_multi_keyword_search( + self, queryset, search_text, similarity_threshold + ): + # Multi-keyword search with ranking: + # 1. Exact title matches + # 2. Trigram similarity + # 3. Substring fallback + keywords = [ + kw.strip().lower() for kw in search_text.split() + if kw.strip() + ] + if not keywords: + return queryset.annotate( + keyword_coverage=Value(0, output_field=IntegerField()), + total_matching_fields=Value( + 0, output_field=IntegerField() + ), + avg_relevance_score=Value( + 0.0, output_field=FloatField() + ), + max_similarity=Value(0.0, output_field=FloatField()), + exact_title_match_flag=Value( + 0, output_field=IntegerField() + ), + trigram_match=Value(0, output_field=IntegerField()), + icontains_match=Value(0, output_field=IntegerField()) + ) + + doc_type_subquery = KeyValue.objects.filter( + media=OuterRef('pk'), + key__iregex=r'^document[ _]type$' + ).values('value')[:1] + + queryset = queryset.annotate( + doc_type=Subquery(doc_type_subquery, output_field=CharField()), + exact_title_match=Case( + When(title__iexact=search_text.strip(), then=Value(1)), + default=Value(0), + output_field=IntegerField() + ) + ) + + keyword_annotations = {} + for i, keyword in enumerate(keywords): + keyword_annotations.update({ + f'title_sim_{i}': Coalesce( + TrigramSimilarity('title', keyword), + Value(0.0, output_field=FloatField()) + ), + f'org_sim_{i}': Coalesce( + TrigramSimilarity('organization_name', keyword), + Value(0.0, output_field=FloatField()) + ), + f'doc_type_sim_{i}': Coalesce( + TrigramSimilarity('doc_type', keyword), + Value(0.0, output_field=FloatField()) + ), + f'tag_sim_{i}': Coalesce( + Subquery( + Tag.objects.filter( + medias=OuterRef('pk') + ).annotate( + similarity=TrigramSimilarity('name', keyword) + ).values('similarity').order_by('-similarity')[:1] + ), + Value(0.0, output_field=FloatField()) + ), + f'media_type_display_sim_{i}': Coalesce( + TrigramSimilarity('media_type_display', keyword), + Value(0.0, output_field=FloatField()) + ) + }) + + queryset = queryset.annotate(**keyword_annotations) + + # Aggregate scores across keywords + total_matching_fields = Value( + 0, output_field=IntegerField() + ) + total_relevance_score = Value( + 0.0, output_field=FloatField() + ) + max_similarity_overall = Value( + 0.0, output_field=FloatField() + ) + keyword_coverage_score = Value( + 0, output_field=IntegerField() + ) + + for i, keyword in enumerate(keywords): + keyword_matching_fields = ( + Case( + When( + **{f'title_sim_{i}__gte': similarity_threshold}, + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + + Case( + When( + **{f'org_sim_{i}__gte': similarity_threshold}, + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + + Case( + When( + **{f'doc_type_sim_{i}__gte': similarity_threshold}, + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + + Case( + When( + **{f'tag_sim_{i}__gte': similarity_threshold}, + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + + Case( + When( + **{ + f'media_type_display_sim_{i}__gte': + similarity_threshold + }, + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + ) + + # Weighted relevance score + keyword_relevance = ( + 2.0 * F(f'title_sim_{i}') + + 1.8 * F(f'tag_sim_{i}') + + 1.6 * F(f'doc_type_sim_{i}') + + 1.4 * F(f'org_sim_{i}') + + 1.2 * F(f'media_type_display_sim_{i}') + ) + + keyword_max_sim = Greatest( + f'title_sim_{i}', + f'org_sim_{i}', + f'doc_type_sim_{i}', + f'tag_sim_{i}', + f'media_type_display_sim_{i}' + ) + + keyword_has_match = Case( + When( + Q( + **{f'title_sim_{i}__gte': similarity_threshold} + ) | + Q( + **{f'org_sim_{i}__gte': similarity_threshold} + ) | + Q( + **{f'doc_type_sim_{i}__gte': similarity_threshold} + ) | + Q( + **{f'tag_sim_{i}__gte': similarity_threshold} + ) | + Q( + **{ + f'media_type_display_sim_{i}__gte': + similarity_threshold + } + ), + then=Value(1) + ), + default=Value(0), + output_field=IntegerField() + ) + + total_matching_fields = ( + total_matching_fields + keyword_matching_fields + ) + total_relevance_score = ( + total_relevance_score + keyword_relevance + ) + max_similarity_overall = Greatest( + max_similarity_overall, keyword_max_sim + ) + keyword_coverage_score = ( + keyword_coverage_score + keyword_has_match + ) + + queryset = queryset.annotate( + keyword_coverage=keyword_coverage_score, + total_matching_fields=total_matching_fields, + avg_relevance_score=total_relevance_score / len(keywords), + max_similarity=max_similarity_overall + ) + + exact_title_condition = Q(title__iexact=search_text.strip()) + trigram_condition = Q(max_similarity__gte=similarity_threshold) + + # Substring fallback + icontains_condition = ( + Q(title__icontains=search_text) | + Q(organization_name__icontains=search_text) | + Q(doc_type__icontains=search_text) | + Q(media_type_display__icontains=search_text) + ) + + # Add tags substring check + tag_icontains_condition = Q( + id__in=Subquery( + Tag.objects.filter( + medias=OuterRef('pk'), + name__icontains=search_text + ).values('medias__id') + ) + ) + icontains_condition |= tag_icontains_condition + + queryset = queryset.annotate( + exact_title_match_flag=Case( + When(exact_title_condition, then=Value(1)), + default=Value(0), + output_field=IntegerField() + ), + trigram_match=Case( + When(trigram_condition, then=Value(1)), + default=Value(0), + output_field=IntegerField() + ), + icontains_match=Case( + When(icontains_condition, then=Value(1)), + default=Value(0), + output_field=IntegerField() + ) + ) + + # Filter and order by ranking + queryset = queryset.filter( + Q(exact_title_match_flag=1) | + Q(trigram_match=1) | + Q(icontains_match=1) + ) + + return queryset.order_by( + '-exact_title_match_flag', + '-keyword_coverage', + '-total_matching_fields', + '-avg_relevance_score', + '-max_similarity', + '-trigram_match', + '-icontains_match' + ) + + def _apply_custom_filters(self, queryset): + # Extract filter parameters + tags_param = self.request.query_params.get( + 'tags', '' + ).strip() + key_values_param = self.request.query_params.get( + 'key_values', '' + ).strip() + organization = self.request.query_params.get( + 'organizations', '' + ).strip() + media_type = self.request.query_params.get( + 'media_types', '' + ).strip() + resource_type = self.request.query_params.get( + 'resource_types', '' + ).strip() + priority = self.request.query_params.get( + 'priorities', '' + ).strip() + + filter_conditions = Q() + + if tags_param: + tags_list = [ + t.strip() for t in tags_param.split(",") + if t.strip() + ] + if tags_list: + tag_conditions = Q() + for tag in tags_list: + tag_conditions |= Q( + id__in=Subquery( + Tag.objects.filter( + medias=OuterRef('pk'), + name__icontains=tag + ).values('medias__id') + ) + ) + filter_conditions &= tag_conditions + + if key_values_param: + kv_pairs = {} + for kv in key_values_param.split(","): + if ":" in kv: + k, v = kv.split(":", 1) + kv_pairs[k.strip()] = v.strip() + + for key, value in kv_pairs.items(): + filter_conditions &= Q( + id__in=Subquery( + KeyValue.objects.filter( + media=OuterRef('pk'), + key__iexact=key, + value__icontains=value + ).values('media__id') + ) + ) + + if organization: + organizations_list = [ + org.strip() for org in organization.split(",") + if org.strip() + ] + if organizations_list: + org_conditions = Q() + for org in organizations_list: + org_conditions |= Q( + organization__name__icontains=org + ) + filter_conditions &= org_conditions + + if resource_type: + resource_types_list = [ + rt.strip() for rt in resource_type.split(",") + if rt.strip() + ] + if resource_types_list: + rt_conditions = Q() + for rt in resource_types_list: + rt_conditions |= Q( + id__in=Subquery( + KeyValue.objects.filter( + media=OuterRef('pk'), + key__iregex=r'^document[ _]type$', + value__icontains=rt + ).values('media__id') + ) + ) + filter_conditions &= rt_conditions + + if media_type: + requested_types = [ + mt.strip() for mt in media_type.split(",") + if mt.strip() + ] + media_types_list = self._resolve_media_types( + requested_types + ) + if media_types_list: + filter_conditions &= Q( + media_type__in=media_types_list + ) + + if priority: + filter_conditions &= Q(priority=priority) + + if filter_conditions: + queryset = queryset.filter(filter_conditions) + + return queryset + + def get_serializer_class(self): + if self.action == 'list': + return MediaListSerializer + return MediaDetailSerializer + + def _resolve_keyword_to_mime_types(self, keyword): + # Convert file extension to MIME type + from chatbot.models import FileTypeChoices + + keyword_lower = keyword.lower().strip() + resolved_types = [] + + mime_type = FileTypeChoices.get_mime_from_extension( + keyword_lower + ) + if mime_type: + resolved_types.append(mime_type) + return resolved_types + + valid_extensions = FileTypeChoices.get_valid_extensions() + if keyword_lower in valid_extensions: + mime_type = FileTypeChoices.get_mime_from_extension( + keyword_lower + ) + if mime_type: + resolved_types.append(mime_type) + return resolved_types + + # Fallback: partial matches + for choice in FileTypeChoices.choices: + mime_type = choice[0] + display_name = choice[1] if len(choice) > 1 else mime_type + + if (keyword_lower in mime_type.lower() or + keyword_lower in display_name.lower() or + mime_type.lower().endswith(f'/{keyword_lower}') or + mime_type.lower().startswith(f'{keyword_lower}/')): + resolved_types.append(mime_type) + + return resolved_types if resolved_types else None + + def _resolve_media_types(self, requested_types): + resolved_types = [] + + for requested_type in requested_types: + requested_lower = requested_type.lower().strip() + + if '/' in requested_type: + resolved_types.append(requested_type) + continue + + mime_type = FileTypeChoices.get_mime_from_extension( + requested_lower + ) + if mime_type: + resolved_types.append(mime_type) + continue + + matches = [] + for choice in FileTypeChoices.choices: + mime_type = choice[0] + display_name = ( + choice[1] if len(choice) > 1 else mime_type + ) + + if (requested_lower in mime_type.lower() or + requested_lower in display_name.lower() or + mime_type.lower().endswith( + f'/{requested_lower}' + ) or + mime_type.lower().startswith( + f'{requested_lower}/' + )): + matches.append(mime_type) + + resolved_types.extend(matches) + + if not matches and requested_type not in resolved_types: + resolved_types.append(requested_type) + + return list(dict.fromkeys(resolved_types)) + + @action(detail=False, methods=['get']) + def master_list(self, request): + # Return master list of filters + from chatbot.models import PriorityChoices + + queryset = self.filter_queryset(self.get_queryset()) + + organizations_data = ( + queryset + .exclude(organization__slug__isnull=True) + .exclude(organization__slug='') + .values('organization__name', 'organization__slug') + .annotate( + name=F('organization__name'), + slug=F('organization__slug') + ) + .distinct() + ) + + organizations = [] + seen_slugs = set() + for org in organizations_data: + if org['slug'] and org['slug'] not in seen_slugs: + organizations.append({ + 'name': ( + org['name'] if org['name'] + else org['slug'].title() + ), + 'slug': org['slug'] + }) + seen_slugs.add(org['slug']) + + organizations = sorted( + organizations, key=lambda x: x['name'].lower() + ) + + media_types = [] + media_type_counts = dict( + queryset.values_list('media_type') + .annotate(count=Count('id')) + .values_list('media_type', 'count') + ) + + for choice in FileTypeChoices.choices: + mime_type = choice[0] + display_name = choice[1] + count = media_type_counts.get(mime_type, 0) + if count > 0: + media_types.append({ + 'value': mime_type, + 'display': display_name, + 'count': count + }) + + resource_types = [] + document_type_data = ( + KeyValue.objects + .filter( + key__iregex=r'^document[ _]type$', + media__in=queryset + ) + .values('value') + .annotate(count=Count('media', distinct=True)) + .order_by('value') + ) + + for item in document_type_data: + document_type_value = item['value'] + count = item['count'] + + if document_type_value and count > 0: + display_name = document_type_value.replace( + '_', ' ' + ).title() + resource_types.append({ + 'value': document_type_value, + 'display': display_name, + 'count': count + }) + + priorities = [] + priority_counts = dict( + queryset.values_list('priority') + .annotate(count=Count('id')) + .values_list('priority', 'count') + ) + + for choice in PriorityChoices.choices: + priority_value = choice[0] + count = priority_counts.get(priority_value, 0) + if count > 0: + priorities.append({ + 'value': priority_value, + 'display': ( + choice[1] if len(choice) > 1 + else priority_value + ), + 'count': count + }) + + tags = list( + Tag.objects + .filter(medias__in=queryset) + .values('id', 'name') + .annotate(count=Count('medias')) + .order_by('name') + .distinct() + ) + + return Response({ + 'total_count': queryset.count(), + 'organizations': organizations, + 'media_types': media_types, + 'resource_types': resource_types, + 'priorities': priorities, + 'tags': tags + }) + + @action(detail=True, methods=['get']) + def related_media(self, request, pk=None): + # Return related media (siblings and similar tags) + media = self.get_object() + + siblings = Media.objects.none() + if media.parent: + siblings = Media.objects.filter( + parent=media.parent, + display_mode=FileDisplayMode.VISIBLE + ).exclude(id=media.id) + + similar_tags = Media.objects.none() + if media.tags.exists(): + tag_ids = media.tags.values_list('id', flat=True) + similar_tags = Media.objects.filter( + tags__in=tag_ids, + display_mode=FileDisplayMode.VISIBLE + ).exclude(id=media.id).distinct() + + related = (siblings | similar_tags).distinct()[:20] + + serializer = MediaListSerializer(related, many=True) + return Response({ + 'media_id': media.id, + 'related_count': related.count(), + 'related_media': serializer.data + }) + + +class MediaSearchV2View(APIView): + # Vector database search API + VALID_ORDERING_FIELDS = [ + 'id', 'name', 'created_at', 'updated_at', 'priority', + 'media_type', 'organization', 'title', 'score' + ] + + def get(self, request, format=None): + query = request.query_params.get('q', '').strip() + + try: + limit = int( + request.query_params.get('limit', 1000000000) + ) + offset = int(request.query_params.get('offset', 0)) + except ValueError: + return Response({ + "error": "Invalid limit or offset parameter", + "count": 0, + "next": None, + "previous": None, + "results": [] + }, status=status.HTTP_400_BAD_REQUEST) + + # Extract filter parameters (with backward compatibility) + tags = self._parse_list_param( + request.query_params.get('tags', '') + ) + if not tags: + tags = self._parse_list_param( + request.query_params.get('categories', '') + ) + + organizations = self._parse_list_param( + request.query_params.get('organizations', '') + ) + + resource_types = self._parse_list_param( + request.query_params.get('resource_types', '') + ) + if not resource_types: + resource_types = self._parse_list_param( + request.query_params.get('resource_type', '') + ) + + media_types = self._parse_list_param( + request.query_params.get('media_types', '') + ) + if not media_types: + media_types = self._parse_list_param( + request.query_params.get('file_type', '') + ) + + # Determine ordering: score for search, user choice otherwise + ordering_param = request.query_params.get( + 'ordering', '' + ).strip() + + if query: + # Use score-based ordering for search queries + ordering = 'score' + else: + # Use user's ordering or default to newest first + ordering = ordering_param if ordering_param else '-created_at' + + ordering_field, ordering_reverse = self._parse_ordering(ordering) + + # Fetch large batch for proper sorting and pagination + top_k = max(1000, offset + limit * 2) + from chatbot.models import CompanyBot + company_bot = CompanyBot.objects.filter(route='/sg_search_bot').first() + filter_score = company_bot.filter_score if company_bot else 0 + + other_params = company_bot.other_params if company_bot.other_params else {} + if other_params and isinstance(other_params, str): + other_params = json_repair.repair_json(other_params, return_objects=True) + detail_filter_score = other_params.get("detail_filter_score", None) + + # Query vector database + vector_response = query_database_with_metadata( + query=query if query else None, + top_k=top_k, + filter_score=filter_score, + detail_filter_score=detail_filter_score, + categories=tags if tags else None, + organizations=organizations if organizations else None, + resource_type=resource_types if resource_types else None, + file_type=None + ) + + if vector_response.get('error'): + error_status = vector_response.get('status_code', 500) + return Response({ + "error": vector_response.get( + 'message', 'Vector database error' + ), + "count": 0, + "next": None, + "previous": None, + "results": [], + "search_metadata": { + "query": query, + "vector_db_error": True + } + }, status=error_status) + + all_results = vector_response.get('results', []) + print("all_results len: ", len(all_results)) + # Apply content exclusion filter + all_results = self._apply_content_exclusion_filter_v2( + all_results + ) + print("all_results len: ", len(all_results)) + + if media_types: + all_results = self._apply_media_type_filter(all_results, media_types) + + total_results = len(all_results) + print("total_results len: ", total_results) + + # Apply ordering + if ordering_field and all_results: + all_results = self._apply_ordering( + all_results, ordering_field, ordering_reverse + ) + + # Apply pagination + paginated_results = ( + all_results[offset:offset + limit] + if offset < len(all_results) else [] + ) + + serializer = MediaSearchResultSerializer( + paginated_results, many=True + ) + + # Build pagination URLs + base_url = request.build_absolute_uri(request.path) + next_url = None + previous_url = None + + if offset + limit < total_results: + next_offset = offset + limit + next_url = ( + f"{base_url}?q={query}&limit={limit}" + f"&offset={next_offset}" + ) + if ordering_param: + next_url += f"&ordering={ordering}" + if tags: + next_url += f"&tags={','.join(tags)}" + if organizations: + next_url += f"&organizations={','.join(organizations)}" + if resource_types: + next_url += f"&resource_types={','.join(resource_types)}" + if media_types: + next_url += f"&media_types={','.join(media_types)}" + + if offset > 0: + previous_offset = max(0, offset - limit) + previous_url = ( + f"{base_url}?q={query}&limit={limit}" + f"&offset={previous_offset}" + ) + if ordering_param: + previous_url += f"&ordering={ordering}" + if tags: + previous_url += f"&tags={','.join(tags)}" + if organizations: + previous_url += f"&organizations={','.join(organizations)}" + if resource_types: + previous_url += f"&resource_types={','.join(resource_types)}" + if media_types: + previous_url += f"&media_types={','.join(media_types)}" + + print("len(serializer.data): ", len(serializer.data)) + response_data = { + "count": total_results, + "next": next_url, + "previous": previous_url, + "results": serializer.data, + "search_metadata": { + "query": query, + "top_k": top_k, + "offset": offset, + "limit": limit, + "ordering": ordering, + "returned_results": len(serializer.data), + "search_config": vector_response.get( + 'search_config', {} + ) + } + } + + return Response(response_data, status=status.HTTP_200_OK) + + def _apply_media_type_filter(self, results, requested_media_types): + """ + Filter results by actual media type, considering source document children. + This ensures that when a source document child exists, we filter by the child's + media type, not the parent's media type. + """ + filtered_results = [] + + for result in results: + source_id = result.get('source_id') + try: + source_id_int = int(source_id) if source_id else None + except (ValueError, TypeError): + source_id_int = None + + if not source_id_int: + continue + + try: + media_obj = Media.objects.prefetch_related( + 'subdocuments', + 'subdocuments__key_values' + ).only('id', 'media_type').get(id=source_id_int) + + source_child = media_obj.subdocuments.filter( + key_values__key__iregex=r'^document[ _]type$', + key_values__value__icontains='source document' + ).first() + + # Use source child's media type if exists, otherwise parent's + actual_media_type = source_child.media_type if source_child else media_obj.media_type + + # Check if actual media type matches any requested media type + if actual_media_type in requested_media_types: + filtered_results.append(result) + + except Media.DoesNotExist: + # If media object doesn't exist, skip this result + continue + except Exception: + # If any error occurs, skip this result + continue + + return filtered_results + + def _apply_content_exclusion_filter_v2(self, results): + # Exclude source documents, low scores, and non-visible media + from chatbot.models import CompanyBot + company_bot = CompanyBot.objects.get(route='/sg_search_bot') + + # Filter by relevance score + score_filtered_results = results + + # for result in results: + # if not isinstance(result, dict): + # continue + # + # relevance_score = result.get('score', 0) + # + # if relevance_score >= company_bot.filter_score: + # score_filtered_results.append(result) + + # Get source document media IDs + source_document_media_ids = set( + KeyValue.objects.annotate( + norm_key=Lower('key', output_field=TextField()) + ).filter( + norm_key__iregex=r'^document[ _]type$', + value__icontains='source document' + ).values_list('media_id', flat=True) + ) + + # Get non-visible media IDs + non_visible_media_ids = set( + Media.objects.exclude( + display_mode=FileDisplayMode.VISIBLE + ).values_list('id', flat=True) + ) + + # Filter out excluded media + filtered_results = [] + + for result in score_filtered_results: + source_id = result.get('source_id') + try: + source_id_int = int(source_id) if source_id else None + except (ValueError, TypeError): + source_id_int = None + + # Exclude source documents and non-visible media + if source_id_int and ( + source_id_int in source_document_media_ids or + source_id_int in non_visible_media_ids + ): + continue + + filtered_results.append(result) + + return filtered_results + + def _parse_list_param(self, param_value): + # Parse comma-separated string to list + if not param_value or not param_value.strip(): + return [] + return [ + item.strip() for item in param_value.split(',') + if item.strip() + ] + + def _parse_ordering(self, ordering_param): + # Parse ordering parameter to (field, reverse) tuple + if not ordering_param: + return 'created_at', True + + reverse = ordering_param.startswith('-') + field = ordering_param.lstrip('-') + + if field not in self.VALID_ORDERING_FIELDS: + return 'created_at', True + + # Higher scores should come first + if field == 'score': + reverse = not reverse + + return field, reverse + + def _apply_ordering(self, results, field, reverse=False): + # Sort results by specified field + def get_sort_key(item): + # Extract sort key from result item + metadata = item.get('metadata', {}) + + if field == 'score': + try: + return float(item.get('score', 0) or 0) + except (ValueError, TypeError): + return 0.0 + elif field == 'id': + source_id = ( + item.get('source_id') or item.get('id') or + metadata.get('id') or metadata.get('source_id') + ) + if source_id is None: + return 0 + try: + if isinstance(source_id, str): + return int(source_id) + elif isinstance(source_id, (int, float)): + return int(source_id) + else: + return 0 + except (ValueError, TypeError): + return 0 + elif field == 'name' or field == 'title': + title = metadata.get('title', item.get('title', '')) + return title.lower() if title else '' + elif field == 'created_at': + created_at = metadata.get('created_at', '') + return ( + created_at if created_at + else '1970-01-01T00:00:00' + ) + elif field == 'updated_at': + updated_at = metadata.get('updated_at', '') + return ( + updated_at if updated_at + else '1970-01-01T00:00:00' + ) + elif field == 'priority': + priority = metadata.get('priority', 'P4') + priority_map = { + 'P1': 1, 'P2': 2, 'P3': 3, 'P4': 4 + } + return priority_map.get(priority, 5) + elif field == 'media_type': + media_type = metadata.get('type', '') + return media_type.lower() if media_type else '' + elif field == 'organization': + org = metadata.get('company', '') + return org.lower() if org else '' + else: + return '' + + try: + return sorted(results, key=get_sort_key, reverse=reverse) + except Exception: + return results diff --git a/chatbot/views/Media/media_tracking_views.py b/chatbot/views/Media/media_tracking_views.py new file mode 100644 index 0000000..c47e608 --- /dev/null +++ b/chatbot/views/Media/media_tracking_views.py @@ -0,0 +1,58 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.db.models import F +from chatbot.models.media_models import Media +from shikshalokam.models import Project + + +class MediaViewTrackAPIView(APIView): + """ + Track media view count (intent-based) + """ + + authentication_classes = [] # guest allowed + permission_classes = [] + + def post(self, request, media_id): + Media.objects.filter(id=media_id).update( + view_count=F("view_count") + 1 + ) + + return Response( + {"status": "view tracked"}, + status=status.HTTP_200_OK + ) + + +class MediaDownloadTrackAPIView(APIView): + """ + Track media download count + """ + def post(self, request, media_id): + Media.objects.filter(id=media_id).update( + download_count=F("download_count") + 1 + ) + + return Response( + {"status": "download tracked"}, + status=status.HTTP_200_OK + ) + + +class SolutionDownloadTrackView(APIView): + def post(self, request, project_id): + updated = Project.objects.filter(project_id=project_id).update( + solution_download_count=F("solution_download_count") + 1 + ) + + if not updated: + return Response( + {"error": "Project not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + return Response( + {"status": "solution download tracked"}, + status=status.HTTP_200_OK + ) diff --git a/chatbot/views/Media/media_views.py b/chatbot/views/Media/media_views.py new file mode 100644 index 0000000..eb65b69 --- /dev/null +++ b/chatbot/views/Media/media_views.py @@ -0,0 +1,100 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank +from chatbot.models import Media +from django.db.models import Q + +from chatbot.serializer.media_serializer import MediaDetailSerializer + + +class MediaSearchView(APIView): + """ + GET /media/search/?q=budget+approval + Optional: &limit=50 + Optional: &tags=tag1,tag2 + Optional: &key_values=key1:value1,key2:value2 + """ + + def get(self, request, format=None): + q = request.query_params.get("q", "").strip() + tags_param = request.query_params.get("tags", "").strip() + key_values_param = request.query_params.get("key_values", "").strip() + + if not q and not tags_param and not key_values_param: + return Response({ + "error": "Provide at least q, tags, or key_values" + }, status=status.HTTP_400_BAD_REQUEST) + + limit_param = request.query_params.get("limit") + limit = int(limit_param) if limit_param else None + + # Convert tags and key_values into usable structures + tags_list = [t.strip() for t in tags_param.split(",") if t.strip()] if tags_param else [] + kv_dict = dict(kv.split(":", 1) for kv in key_values_param.split(",") if ":" in kv) if key_values_param else {} + + print(f"Raw query string: {q}") + print(f"Limit: {limit}") + print(f"Tags filter: {tags_list}") + print(f"Key-Value filter: {kv_dict}") + + # ---------- FTS path ---------- + if q: + query = SearchQuery(q, search_type="plain") + vector = SearchVector("extracted_text", weight="A") + + fts_qs = ( + Media.objects + .annotate(rank=SearchRank(vector, query)) + .filter(rank__gte=0.3) + .distinct() + .order_by("-rank", "-created_at")[:limit] + ) + print(f"\nFTS QS: {fts_qs}\n") + print("SQL being run for FTS:") + print(str(fts_qs.query)) + else: + fts_qs = None + print("\nNo FTS search performed since q is empty") + + qs = None + + if fts_qs is not None and fts_qs.exists(): + qs = fts_qs + for media in fts_qs: + print(f"Media: {media.name}, Score: {media.rank}") + + print(f"\nUsing FTS results, count: {fts_qs.count()}\n") + elif tags_list or kv_dict: + # Only run fallback if tags or key_values are provided + print("\nFTS returned 0 results, using fallback search on tags & key-values") + + tags_qs = Media.objects.none() + kv_qs = Media.objects.none() + + # Search in specified tags + for t in tags_list: + print(f"Searching for specified tag: '{t}'") + tags_qs |= Media.objects.filter(tags__name__icontains=t) + + # Search in specified key-values + for k, v in kv_dict.items(): + print(f"Searching for key-value: '{k}:{v}'") + kv_qs |= Media.objects.filter( + Q(key_values__key__icontains=k) & Q(key_values__value__icontains=v) + ) + + qs = (tags_qs | kv_qs).distinct().order_by("-created_at")[:limit] + + if qs: + print(f"\nQS after combining FTS/fallback: {qs}\n") + print("SQL being run for final QS:") + print(str(qs.query)) + + data = MediaDetailSerializer(qs, many=True).data + print(f"Found {len(data)} results\n") + else: + print("QS returned no results, fallback disabled") + data = [] + + return Response({"count": len(data), "results": data}) diff --git a/chatbot/views/Media/save_views.py b/chatbot/views/Media/save_views.py new file mode 100644 index 0000000..26f5daa --- /dev/null +++ b/chatbot/views/Media/save_views.py @@ -0,0 +1,1158 @@ +import re +import traceback +from pathlib import Path +import requests +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse +from django.views import View +from chatbot.models import Media, KeyValue, Profile, FileTypeChoices, CompanyBot, Company, FileDisplayMode +from chatbot.models.media_models import MediaImage, MediaTypeChoices +import json +import os +from django.core.cache import cache + +from chatbot.utils.knowledge_service.auto_tag_utils import TagProcessor +from chatbot.utils.knowledge_service.base_task_utils import determine_media_type_from_url +from chatbot.utils.knowledge_service.cache_manager import CacheManager +from chatbot.utils.knowledge_service.duplicate_detector import DuplicateDetector +from django.core.files.base import ContentFile +import base64 +from django.utils.text import slugify +from django.conf import settings + +ENABLE_SIMILARITY_CHECK = getattr(settings, 'BATCH_UPLOAD_ENABLE_SIMILARITY_CHECK', False) +CACHE_TIMEOUT = getattr(settings, 'BATCH_UPLOAD_CACHE_TIMEOUT', 7200) + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaSaveView(View): + """API endpoint for saving batch media data with fault tolerance""" + + def clean_text_to_title_case(self, text): + """Convert text to title case, handling common edge cases""" + if not text: + return text + + # Convert to string and strip whitespace + text = str(text).strip() + + # Handle acronyms and special cases + words = text.split() + cleaned_words = [] + + for word in words: + # Keep acronyms (all caps) as is + if word.isupper() and len(word) > 1: + cleaned_words.append(word) + else: + # Convert to title case + cleaned_words.append(word.title()) + + return ' '.join(cleaned_words) + + + def clean_key_for_ordered_list(self, key): + """ + Remove leading numbers, dots, and special characters from keys + that will be displayed in ordered lists on the frontend. + Examples: + "1. THEORY OF CHANGE" -> "THEORY OF CHANGE" + "2.1 Design Philosophy" -> "Design Philosophy" + """ + if not key: + return key + + cleaned = re.sub(r'^\d+\.?\s*', '', key.strip()) + + return cleaned + + def get_or_create_source_document_media(self, source_doc_url, parent_media, company_bot_id, markdown_content): + """ + Download and save source document as a Media object if not already saved. + Returns the Media object for the source document. + """ + # Use a class-level cache to track saved source documents within this batch + if not hasattr(self, '_source_doc_cache'): + self._source_doc_cache = {} + + # Check if we've already processed this source document + if source_doc_url in self._source_doc_cache: + return self._source_doc_cache[source_doc_url] + + try: + # Use the separated function to determine media type and filename + media_type, filename, response = determine_media_type_from_url(source_doc_url, parent_media) + + if not media_type or not filename: + print(f"media_type: {media_type} and filename: {filename}") + print(f"Error creating source document media for {source_doc_url}: Media type or file name is null.") + return None + print(f"Final filename: {filename}, media_type: {media_type}") + + # Create Media object for source document + source_media = Media( + name=filename, + media_type=media_type, + priority=parent_media.priority, + company_bot_id=company_bot_id, + parent=parent_media, + organization=parent_media.organization, + display_mode=FileDisplayMode.PRIVATE + ) + + file_content = response.content + + # Save the main file + if file_content: + # Create a fresh ContentFile for the main file + file_content_file = ContentFile(file_content) + source_media.file.save(filename, file_content_file, save=False) + print(f"Saved main file: {filename} ({len(file_content)} bytes)") + + # Save the markdown file if content exists + if markdown_content: + base_filename = os.path.splitext(filename)[0] # Remove extension + markdown_filename = f"Markdown_{base_filename}.md" + + # Ensure markdown content is properly encoded + if isinstance(markdown_content, str): + markdown_content_bytes = markdown_content.encode('utf-8') + else: + markdown_content_bytes = markdown_content + + # Create a fresh ContentFile for the markdown + markdown_content_file = ContentFile(markdown_content_bytes) + source_media.markdown_file.save(markdown_filename, markdown_content_file, save=False) + print(f"Saved markdown file: {markdown_filename} ({len(markdown_content_bytes)} bytes)") + + source_media.save() + + # Add reference to original URL + KeyValue.objects.create( + media=source_media, + key='ORIGINAL_URL', + value=source_doc_url + ) + + KeyValue.objects.create( + media=source_media, + key='DOCUMENT_TYPE', + value='Source Document' + ) + + # Cache the result + self._source_doc_cache[source_doc_url] = source_media + + print(f"Created source document media: {source_media.id} - {source_media.name}") + return source_media + + except Exception as e: + print(f"Error creating source document media for {source_doc_url}: {e}") + traceback.print_exc() + return None + + def wait_for_vector_db_save_safe(self, task_id, timeout=30): + """Enhanced waiting with better error handling""" + import time + from celery.result import AsyncResult + + try: + intervals = [0.1, 0.2, 0.5, 1.0, 2.0, 3.0] + start_time = time.time() + attempt = 0 + + while time.time() - start_time < timeout: + try: + task = AsyncResult(task_id) + if task.ready(): + if task.successful(): + return { + 'completed': True, + 'successful': True, + 'result': task.result, + 'wait_time': time.time() - start_time + } + else: + return { + 'completed': True, + 'successful': False, + 'result': f'Vector DB task failed: {task.info}', + 'wait_time': time.time() - start_time, + 'error_type': 'VECTOR_DB_TASK_FAILED' + } + except Exception as poll_error: + print(f"Polling error for task {task_id}: {poll_error}") + + sleep_time = intervals[min(attempt, len(intervals) - 1)] + time.sleep(sleep_time) + attempt += 1 + + return { + 'completed': False, + 'successful': False, + 'result': f'Vector DB save timeout after {timeout}s', + 'wait_time': timeout, + 'error_type': 'VECTOR_DB_TIMEOUT' + } + + except Exception as wait_error: + return { + 'completed': False, + 'successful': False, + 'result': f'Wait error: {str(wait_error)}', + 'error_type': 'WAIT_ERROR' + } + + def save_single_item_with_vector_db_wait_safe(self, item_data, company_bot_id, user_profile, session_id, + bypass_similarity=False): + """Save a single media item with comprehensive error handling""" + file_key = item_data.get('file_key') + filename = item_data.get('filename', 'Unknown') + file_index = item_data.get('file_index') + + # if "fail" in filename.lower(): + # print(f"Forced save failure for {filename}") + # raise ValueError(f"Forced save failure for {filename}") + + try: + company_bot = CompanyBot.objects.get(id=company_bot_id) + company_slug = None + selected_company = None + if item_data.get('organization_slug'): + try: + selected_company = Company.objects.get(slug=item_data['organization_slug']) + if selected_company: + company_slug = selected_company.slug + except Company.DoesNotExist: + pass + + extracted_text = item_data.get('extracted_text', '') + + # Step 1: Similarity check + if ENABLE_SIMILARITY_CHECK and not bypass_similarity: + try: + DuplicateDetector.check_for_duplicates( + extracted_text=extracted_text, + company_slug=company_slug, + trigram_threshold=0, + semantic_threshold=0.85, + trigram_exact_threshold=0.90, + semantic_exact_threshold=0.9 + ) + except Exception as similarity_error: + return { + 'success': False, + 'filename': filename, + 'message': f'Similarity check failed: {str(similarity_error)}', + 'error_type': 'SIMILARITY_CHECK_FAILED', + 'file_index': file_index, + 'file_key': file_key, + 'session_id': session_id, + 'vector_db_saved': False + } + + # Step 2: Retrieve file from cache + file_content = None + file_name = None + + if file_key: + cached_file = CacheManager.get_cached_item(file_key) + if cached_file: + file_content = cached_file.get('content') + file_name = cached_file.get('name') + else: + return { + 'success': False, + 'filename': filename, + 'message': 'File not found in cache for saving', + 'error_type': 'FILE_NOT_FOUND_IN_CACHE', + 'file_index': file_index, + 'file_key': file_key, + 'session_id': session_id, + 'vector_db_saved': False + } + + # Step 3: Create and save media + try: + fallback_description = f"Extracted from {filename}" if filename else '' + description = ( + item_data.get('summary') + or item_data.get('description') + or fallback_description + ) + + organization_instance = None + if item_data.get('organization_slug'): + try: + organization_instance = Company.objects.get(slug=item_data['organization_slug']) + except Company.DoesNotExist: + print(f"Warning: Company with slug {item_data['organization_slug']} not found") + + media = Media( + name=item_data.get('title') or item_data.get('name') or filename, + media_type=item_data.get('media_type', FileTypeChoices.TXT.value), + priority=item_data.get('priority', 'P1'), + description=description, + company_bot_id=company_bot_id, + organization=organization_instance, + ) + + if file_content and file_name: + from django.core.files.base import ContentFile + media.file.save(file_name, ContentFile(file_content), save=False) + + # Create and save markdown file if extracted_text exists + if extracted_text and extracted_text.strip(): + base_name = file_name if file_name else item_data['name'] + if base_name and '.' in base_name: + base_name = os.path.splitext(base_name)[0] + markdown_filename = f"Markdown_{base_name}.md" + # Ensure .md extension + if not markdown_filename.endswith('.md'): + markdown_filename = f"{markdown_filename}.md" + markdown_content = extracted_text.encode('utf-8') + media.markdown_file.save(markdown_filename, ContentFile(markdown_content), save=False) + + # Save and get the vector DB task ID + vector_task_id = media.save() + + except Exception as media_save_error: + return { + 'success': False, + 'filename': filename, + 'message': f'Media save failed: {str(media_save_error)}', + 'error_type': 'MEDIA_SAVE_FAILED', + 'file_index': file_index, + 'file_key': file_key, + 'session_id': session_id, + 'vector_db_saved': False + } + + # Step 4: Process tags and key-values with prioritized company + try: + all_tags = [] + + # Process manual tags with selected company (from dropdown priority) + manual_tags = TagProcessor.process_tags_for_media( + item_data.get('manual_tags', []), + 'manual', + user_profile, + selected_company, + is_manual=True + ) + all_tags.extend(manual_tags) + + # Process auto tags with selected company (from dropdown priority) + auto_tags = TagProcessor.process_tags_for_media( + item_data.get('auto_tags', []), + 'extracted', + user_profile, + selected_company, + is_manual=False + ) + all_tags.extend(auto_tags) + + if all_tags: + media.tags.set(all_tags) + + # Key-value pairs - ensure organization is saved + org_found = False + print("item_data: ", item_data) + for kv in item_data.get('key_values', []): + cleaned_key = self.clean_key_for_ordered_list(kv['key']) + KeyValue.objects.create( + media=media, + key=cleaned_key, + value=kv['value'] + ) + + except Exception as tag_kv_error: + print(f"Warning: Tag/KV processing failed for {filename}: {tag_kv_error}") + + # Step 5: Wait for vector DB save + vector_result = {'successful': True, 'result': 'No vector task'} + if vector_task_id: + vector_result = self.wait_for_vector_db_save_safe(vector_task_id) + + if not vector_result['successful']: + print(f"Vector DB save failed for media {media.id}: {vector_result['result']}") + return { + 'success': False, + 'filename': filename, + 'media_id': media.id, + 'message': f"Saved to database but vector DB failed: {vector_result['result']}", + 'error_type': vector_result.get('error_type', 'VECTOR_DB_FAILED'), + 'file_index': file_index, + 'file_key': file_key, + 'session_id': session_id, + 'vector_db_saved': False, + 'partial_success': True, + 'vector_task_id': vector_task_id, + 'subdocument_results': [], + 'image_results': [] + } + + # Step 5.5: Process source documents if no subdocuments but source_documents exist + source_document_results = [] + source_documents = item_data.get('source_documents', []) + + if not item_data.get('subdocument') and source_documents: + print(f"Processing {len(source_documents)} source documents for main document without subdocuments") + + for source_doc in source_documents: + # source_doc is an object with 'url' and 'exact_content' + source_url = source_doc.get('url') if isinstance(source_doc, dict) else source_doc + + if not source_url: + print(f"Skipping source document with no URL: {source_doc}") + continue + markdown_content = source_doc.get('exact_content', '') + try: + source_media = self.get_or_create_source_document_media( + source_url, + media, + company_bot_id, + markdown_content + ) + if source_media: + source_document_results.append({ + 'success': True, + 'source_media_id': source_media.id, + 'source_url': source_url, + 'title': source_media.name + }) + print(f"Saved source document: {source_media.name} (ID: {source_media.id})") + else: + source_document_results.append({ + 'success': False, + 'error': f'Failed to create source document for {source_url}', + 'source_url': source_url + }) + except Exception as source_error: + print(f"Error creating source document for {source_url}: {source_error}") + source_document_results.append({ + 'success': False, + 'error': str(source_error), + 'source_url': source_url + }) + + # Step 6: Process subdocuments recursively + subdocument_results = [] + if item_data.get('subdocument'): + # Cache subdocuments before processing + self._cache_subdocuments_recursive( + item_data['subdocument'], + session_id, + file_index, + "" + ) + + subdoc_results = self.process_subdocuments_recursive( + item_data['subdocument'], + media, + company_bot_id, + user_profile, + company_slug, + session_id, + file_index, + "", + item_data + ) + subdocument_results.extend(subdoc_results) + + # Step 7: Process images + image_results = [] + if item_data.get('images'): + for index, img_data in enumerate(item_data['images']): + try: + img_result = self.save_media_image(img_data, media, index) + image_results.append(img_result) + except Exception as img_error: + print(f"Warning: Image save failed: {img_error}") + image_results.append({ + 'success': False, + 'error': str(img_error) + }) + + # Step 8: Success - clean up cache + if file_key and cache.get(file_key): + cache.delete(file_key) + print(f"Cleaned up cache for {file_key}") + + return { + 'success': True, + 'filename': filename, + 'media_id': media.id, + 'message': 'Successfully saved', + 'file_index': file_index, + 'vector_db_saved': vector_result['successful'], + 'vector_wait_time': vector_result.get('wait_time', 0), + 'vector_task_id': vector_task_id, + 'subdocument_results': subdocument_results, + 'source_document_results': source_document_results, + 'image_results': image_results + } + + except Exception as unexpected_error: + print(f"Unexpected error processing {filename}: {unexpected_error}") + traceback.print_exc() + return { + 'success': False, + 'filename': filename, + 'message': f'Unexpected error: {str(unexpected_error)}', + 'error_type': 'UNEXPECTED_ERROR', + 'file_index': file_index, + 'file_key': file_key, + 'session_id': session_id, + 'vector_db_saved': False + } + + def _cache_subdocuments_recursive(self, subdocuments, session_id, parent_index, parent_path): + """Cache subdocuments recursively for retry purposes""" + for i, subdoc in enumerate(subdocuments): + current_path = f"{parent_path}_{i}" if parent_path else str(i) + + # Cache this subdocument with all its data + CacheManager.cache_subdocument(subdoc, session_id, parent_index, current_path) + + # Recursively cache nested subdocuments + if subdoc.get('subdocument'): + self._cache_subdocuments_recursive( + subdoc['subdocument'], + session_id, + parent_index, + current_path + ) + + def process_subdocuments_recursive(self, subdocuments, parent_media, company_bot_id, user_profile, + company_slug, session_id, parent_index, parent_path, + source_doc): # Add source_doc parameter + """Recursively process subdocuments at any depth""" + results = [] + + for i, subdoc_data in enumerate(subdocuments): + current_path = f"{parent_path}_{i}" if parent_path else str(i) + subdoc_cache_key = CacheManager.get_cache_key(session_id, 'subdoc', f"{parent_index}_{current_path}") + + try: + # Pass source_doc to save_subdocument + subdoc_result = self.save_subdocument( + subdoc_data, parent_media, company_bot_id, user_profile, company_slug, source_doc + ) + subdoc_result['cache_key'] = subdoc_cache_key + subdoc_result['path'] = current_path + + # If this subdocument has nested subdocuments, process them recursively + if subdoc_data.get('subdocument') and subdoc_result['success']: + subdoc_media_id = subdoc_result['subdoc_media_id'] + subdoc_media = Media.objects.get(id=subdoc_media_id) + + nested_results = self.process_subdocuments_recursive( + subdoc_data['subdocument'], + subdoc_media, + company_bot_id, + user_profile, + company_slug, + session_id, + parent_index, + current_path, + source_doc # Pass source_doc to nested calls + ) + subdoc_result['nested_subdocument_results'] = nested_results + + results.append(subdoc_result) + + except Exception as subdoc_error: + print(f"Warning: Subdocument save failed: {subdoc_error}") + results.append({ + 'success': False, + 'error': str(subdoc_error), + 'cache_key': subdoc_cache_key, + 'path': current_path, + 'title': subdoc_data.get('title', f'Subdocument at {current_path}') + }) + + return results + + def save_subdocument(self, subdoc_data, parent_media, company_bot_id, user_profile, company_slug, source_doc): + """Save a subdocument as a separate Media object linked to parent""" + try: + source_doc_url = subdoc_data.get('source_document') + actual_parent = parent_media + + print("=" * 50) + print("source_doc_url: ", source_doc_url) + print("source_doc: ", source_doc) + print("=" * 50) + + if source_doc_url: + source_documents = source_doc.get('source_documents', []) + markdown_content = '' + for source_document in source_documents: + if source_document.get('url') == source_doc_url: + print("URL MATCHED") + markdown_content = source_document.get('exact_content', '') + break + + print("found markdown content: ", markdown_content) + # Try to get or create the source document media + source_media = self.get_or_create_source_document_media( + source_doc_url, + parent_media, + company_bot_id, + markdown_content + ) + + if source_media: + # Use the source document as the parent instead + actual_parent = source_media + print(f"Using source document {source_media.id} as parent for subdocument") + + file_url = subdoc_data.get('file_url') + if not file_url: + raise ValueError(f"No file URL provided for subdocument") + + # Validate file format based on URL extension before downloading + from urllib.parse import urlparse, unquote + parsed_url = urlparse(file_url) + path = unquote(parsed_url.path) + + # Check if URL has an extension and validate it + if '.' in path: + url_extension = path.rsplit('.', 1)[-1].lower() + if url_extension and not FileTypeChoices.is_valid_extension(url_extension): + raise ValueError(f"Unsupported file format: .{url_extension}") + + print(f"Downloading file from URL: {file_url}") + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } + + try: + response = requests.get(file_url, headers=headers, timeout=30, allow_redirects=True) + response.raise_for_status() + except requests.exceptions.RequestException as e: + error_msg = f"Failed to download file from {file_url}: {str(e)}" + print(f"Error: {error_msg}") + raise ValueError(error_msg) + + # Additional validation based on content-type + content_type = response.headers.get('content-type', '').lower() + + # Map content types to file extensions + content_type_mapping = { + 'application/pdf': 'pdf', + 'application/msword': 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'text/plain': 'txt', + 'text/csv': 'csv', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + } + + # Check if content type is supported + content_extension = None + for mime_type, ext in content_type_mapping.items(): + if mime_type in content_type: + content_extension = ext + break + + if content_extension and not FileTypeChoices.is_valid_extension(content_extension): + raise ValueError(f"Unsupported content type: {content_type}") + + # Determine filename from URL or content-disposition + filename = None + content_disposition = response.headers.get('content-disposition') + if content_disposition: + import re + matches = re.findall('filename="?([^"]+)"?', content_disposition) + if matches: + filename = matches[0] + # Validate filename extension + if '.' in filename: + file_ext = filename.rsplit('.', 1)[-1].lower() + if not FileTypeChoices.is_valid_extension(file_ext): + raise ValueError(f"Unsupported file format in download: .{file_ext}") + + if not filename: + # Extract from URL + from urllib.parse import urlparse, unquote + parsed_url = urlparse(file_url) + path = parsed_url.path + filename = os.path.basename(unquote(path)) + + # For Google Docs/Drive, create appropriate filename based on media type + if 'docs.google.com' in file_url or 'drive.google.com' in file_url: + base_title = subdoc_data.get('title', 'Document') + media_type = subdoc_data.get('media_type', FileTypeChoices.TXT.value) + + # Get extension from media type using the enum's mapping + extension_mapping = FileTypeChoices.get_extension_mapping() + extension = extension_mapping.get(media_type, '.txt') + + filename = f"{slugify(base_title, allow_unicode=True)}{extension}" + + # Ensure filename has an extension + if not os.path.splitext(filename)[1]: + # Add extension based on media type using the enum's mapping + media_type = subdoc_data.get('media_type', FileTypeChoices.TXT.value) + extension_mapping = FileTypeChoices.get_extension_mapping() + extension = extension_mapping.get(media_type, '.txt') + filename += extension + + # Use filename (without extension) as the subdocument title + filename_without_ext = os.path.splitext(filename)[0] if filename else "" + + if filename_without_ext and len(filename_without_ext.strip()) > 0: + subdoc_title = filename_without_ext + print(f"Using filename as title: {subdoc_title}") + else: + llm_title = subdoc_data.get('title', '').strip() + if llm_title and len(llm_title) > 0: + subdoc_title = llm_title + print(f"Using LLM-extracted title: {subdoc_title}") + else: + # Final fallback - create a descriptive title + subdoc_title = f"Document from {Path(urlparse(file_url).path).name or 'linked document'}" + print(f"Using fallback title: {subdoc_title}") + + print(f"Saving subdocument with title: {subdoc_title} (from filename: {filename})") + + # Check for forced failure + # for kv in subdoc_data.get('key_values', []): + # if "fail" in kv.get('value', '').lower(): + # print(f"Forced subdoc extraction failure for {subdoc_title}") + # raise ValueError(f"Forced subdoc extraction failure for {subdoc_title}") + + # Get file content + file_content = response.content + if not file_content: + raise ValueError(f"Downloaded file is empty for URL: {file_url}") + + # IMPORTANT FIX: Get organization from subdocument data FIRST + subdoc_org = subdoc_data.get('organization', '') + + # If subdocument has no organization, try to get from key-values + if not subdoc_org: + for kv in subdoc_data.get('key_values', []): + if kv.get('key') == 'ORGANIZATION' and kv.get('value'): + subdoc_org = kv.get('value') + break + + # If still no organization, get from parent media's key-values + if not subdoc_org: + parent_kvs = KeyValue.objects.filter(media=parent_media, key='ORGANIZATION') + if parent_kvs.exists(): + subdoc_org = parent_kvs.first().value + + # Only use company name as last resort + if not subdoc_org and user_profile and user_profile.company: + subdoc_org = user_profile.company.name + + subdoc_org = self.clean_text_to_title_case(subdoc_org) + print(f"Subdocument organization resolved to: {subdoc_org}") + organization_instance = None + if subdoc_data.get('organization_slug'): + try: + organization_instance = Company.objects.get(slug=subdoc_data['organization_slug']) + except Company.DoesNotExist: + print(f"Warning: Company with slug {subdoc_data['organization_slug']} not found") + + # Get extracted_text for subdocument + subdoc_extracted_text = subdoc_data.get('extracted_text', '') + + # Create subdocument media + subdoc_media = Media( + name=subdoc_data.get('title') or subdoc_title or filename, + media_type=subdoc_data.get('media_type', FileTypeChoices.TXT.value), + priority=parent_media.priority, + description=subdoc_data.get('summary') or subdoc_data.get('description') or '', + company_bot_id=company_bot_id, + parent=actual_parent, + organization=organization_instance, + display_mode=subdoc_data.get('display_mode', FileDisplayMode.VISIBLE), + ) + + # Save the file content - use the original filename + try: + subdoc_media.file.save(filename, ContentFile(file_content), save=False) + print(f"Successfully saved file: {filename}") + except Exception as e: + error_msg = f"Failed to save file content for subdocument: {str(e)}" + print(f"Error: {error_msg}") + raise ValueError(error_msg) + + # Create and save markdown file if extracted_text exists + if subdoc_extracted_text and subdoc_extracted_text.strip(): + base_filename = os.path.splitext(filename)[0] + markdown_filename = f"Markdown_{base_filename}.md" + + # Ensure .md extension + if not markdown_filename.endswith('.md'): + markdown_filename = f"{markdown_filename}.md" + markdown_content = subdoc_extracted_text.encode('utf-8') + subdoc_media.markdown_file.save(markdown_filename, ContentFile(markdown_content), save=False) + + # Save the media object + subdoc_media.save() + + selected_company = None + if subdoc_data.get('organization_slug'): + try: + selected_company = Company.objects.get(slug=subdoc_data['organization_slug']) + except Company.DoesNotExist: + pass + + if not selected_company and user_profile: + selected_company = user_profile.company + + if not selected_company: + company_bot = CompanyBot.objects.get(id=company_bot_id) + selected_company = company_bot.company + + all_tags = [] + + manual_tags = subdoc_data.get('manual_tags', []) + if manual_tags: + manual_tag_objs = TagProcessor.process_tags_for_media( + manual_tags, + 'manual', + user_profile, + selected_company, + is_manual=True + ) + all_tags.extend(manual_tag_objs) + + auto_tags = subdoc_data.get('auto_tags', []) + if auto_tags: + auto_tag_objs = TagProcessor.process_tags_for_media( + auto_tags, + 'extracted', + user_profile, + selected_company, + is_manual=False + ) + all_tags.extend(auto_tag_objs) + + if all_tags: + subdoc_media.tags.set(all_tags) + + # Key-value pairs - handle organization specially + for kv in subdoc_data.get('key_values', []): + cleaned_key = self.clean_key_for_ordered_list(kv['key']) + if cleaned_key == 'DOCUMENT_TYPE': + doc_type_value = kv['value'] + if isinstance(doc_type_value, dict): + actual_value = doc_type_value.get('type', '') + actual_value = actual_value.title() if actual_value else '' + else: + actual_value = doc_type_value.title() if doc_type_value else '' + + KeyValue.objects.create( + media=subdoc_media, + key='DOCUMENT_TYPE', + value=actual_value + ) + else: + KeyValue.objects.create( + media=subdoc_media, + key=cleaned_key, + value=kv['value'] + ) + + print(f"Saved {len(subdoc_data.get('key_values', []))} key-values for subdoc: {subdoc_title}") + + # Process subdocument images + if subdoc_data.get('images'): + for index, img_data in enumerate(subdoc_data['images']): + self.save_media_image(img_data, subdoc_media, index) + + return { + 'success': True, + 'subdoc_media_id': subdoc_media.id, + 'title': subdoc_media.name + } + + except Exception as e: + print(f"Error saving subdocument: {e}") + traceback.print_exc() + return { + 'success': False, + 'error': str(e), + 'title': subdoc_data.get('title', 'Unknown subdocument') + } + + def save_media_image(self, img_data, media, index): + """Save image associated with media""" + try: + if img_data.get('base64'): + try: + # Extract image format from base64 string + base64_str = img_data['base64'] + if base64_str.startswith('data:'): + mime_start = base64_str.find('image/') + 6 + mime_end = base64_str.find(';', mime_start) + image_format = base64_str[mime_start:mime_end] + base64_data = base64_str.split(',')[1] + else: + image_format = img_data.get('format', 'png') + base64_data = base64_str + + # Decode base64 to bytes + image_bytes = base64.b64decode(base64_data) + base_name, _ = os.path.splitext(media.name) + safe_base = slugify(base_name, allow_unicode=True) + file_name = f"img_{safe_base}_{index}.{image_format}" + + media_image = MediaImage( + name=file_name, + media=media, + page=img_data.get('page'), + index=img_data.get('index', index), + width=img_data.get('width'), + height=img_data.get('height'), + base64_str=img_data.get('base64', '') + ) + + # Create file + media_image.file.save(file_name, ContentFile(image_bytes), save=False) + + # Set media type + if image_format.lower() in ['jpg', 'jpeg']: + media_image.media_type = MediaTypeChoices.JPEG + elif image_format.lower() == 'png': + media_image.media_type = MediaTypeChoices.PNG + elif image_format.lower() == 'svg': + media_image.media_type = MediaTypeChoices.SVG + elif image_format.lower() == 'webp': + media_image.media_type = MediaTypeChoices.WEBP + + media_image.save() + + return { + 'success': True, + 'image_id': media_image.id, + 'page': media_image.page + } + + except Exception as e: + print(f"Error processing image base64: {e}") + return { + 'success': False, + 'error': str(e) + } + + except Exception as e: + print(f"Error saving media image: {e}") + return { + 'success': False, + 'error': str(e) + } + + def post(self, request): + try: + self._source_doc_cache = {} + data = json.loads(request.body) + company_bot_id = data.get('company_bot_id') + media_items = data.get('items', []) + session_id = data.get('session_id') + + results = [] + stats = { + 'total': len(media_items), + 'successful': 0, + 'failed': 0, + 'partial_success': 0, + 'timeouts': 0, + 'similarity_failures': 0 + } + + # Get current user's profile + try: + user_profile = Profile.objects.get(email=request.user.email) + except Profile.DoesNotExist: + user_profile = None + + print(f"Starting batch save for {len(media_items)} files") + + # Process each file with fault tolerance + for i, item_data in enumerate(media_items): + filename = item_data.get('filename', f'File_{i}') + print(f"Processing file {i + 1}/{len(media_items)}: {filename}") + + try: + bypass_similarity = item_data.get('bypass_similarity', False) + result = self.save_single_item_with_vector_db_wait_safe( + item_data=item_data, + company_bot_id=company_bot_id, + user_profile=user_profile, + session_id=session_id, + bypass_similarity=bypass_similarity + ) + + # Track statistics + if result['success']: + stats['successful'] += 1 + else: + stats['failed'] += 1 + if result.get('partial_success'): + stats['partial_success'] += 1 + if result.get('error_type') in ['VECTOR_DB_TIMEOUT', 'WAIT_ERROR']: + stats['timeouts'] += 1 + if result.get('error_type') == 'SIMILARITY_CHECK_FAILED': + stats['similarity_failures'] += 1 + + results.append(result) + print( + f"File {i + 1} result: {'✓' if result['success'] else '✗'} - {result.get('message', 'No message')}") + + except Exception as item_error: + print(f"Critical error processing {filename}: {item_error}") + stats['failed'] += 1 + results.append({ + 'success': False, + 'filename': filename, + 'message': f'Critical processing error: {str(item_error)}', + 'error_type': 'CRITICAL_ERROR', + 'file_index': item_data.get('file_index', i), + 'file_key': item_data.get('file_key'), + 'session_id': session_id, + 'vector_db_saved': False + }) + + # Preserve cache for failed files + failed_cache_keys = [] + for r in results: + if not r['success'] and r.get('file_key'): + failed_cache_keys.append(r['file_key']) + # Also preserve cache for failed subdocuments + if r.get('subdocument_results'): + for subdoc_result in r['subdocument_results']: + if not subdoc_result.get('success') and subdoc_result.get('cache_key'): + failed_cache_keys.append(subdoc_result['cache_key']) + + if failed_cache_keys: + CacheManager.extend_cache_timeout(failed_cache_keys) + + # Generate summary message + summary_message = self.generate_batch_summary(stats) + print(f"Batch complete: {summary_message}") + + return JsonResponse({ + 'success': True, + 'results': results, + 'stats': stats, + 'summary_message': summary_message, + 'session_id': session_id + }) + + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Invalid JSON data' + }, status=400) + except Exception as batch_error: + print(f"Batch processing error: {batch_error}") + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': f'Batch processing failed: {str(batch_error)}' + }, status=500) + + def generate_batch_summary(self, stats): + """Generate human-readable batch summary""" + total = stats['total'] + successful = stats['successful'] + failed = stats['failed'] + + if successful == total: + return f"All {total} files processed successfully!" + elif successful == 0: + return f"All {total} files failed to process." + else: + message_parts = [f"{successful}/{total} files successful"] + if failed > 0: + message_parts.append(f"{failed} failed") + if stats['timeouts'] > 0: + message_parts.append(f"{stats['timeouts']} timed out") + if stats['similarity_failures'] > 0: + message_parts.append(f"{stats['similarity_failures']} similarity check failures") + if stats['partial_success'] > 0: + message_parts.append(f"{stats['partial_success']} partial successes") + + return ", ".join(message_parts) + "." + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaRetrySaveView(View): + """API endpoint for retrying save of a single media item""" + + def post(self, request): + try: + data = json.loads(request.body) + item_data = data.get('item_data') + company_bot_id = data.get('company_bot_id') + session_id = data.get('session_id') + bypass_similarity = data.get('bypass_similarity', False) + is_subdocument = data.get('is_subdocument', False) + parent_media_id = data.get('parent_media_id') + + # Get current user's profile + try: + user_profile = Profile.objects.get(email=request.user.email) + except Profile.DoesNotExist: + user_profile = None + + if is_subdocument and parent_media_id: + # Retry subdocument save + try: + parent_media = Media.objects.get(id=parent_media_id) + company_bot = CompanyBot.objects.get(id=company_bot_id) + company_slug = company_bot.company.slug + + save_view = BatchMediaSaveView() + result = save_view.save_subdocument( + subdoc_data=item_data, + parent_media=parent_media, + company_bot_id=company_bot_id, + user_profile=user_profile, + company_slug=company_slug, + source_doc=data.get('source_document', {}) + ) + + return JsonResponse({ + 'success': True, + 'result': result + }) + except Exception as e: + print(f"Subdocument retry error: {e}") + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=400) + else: + # Retry main document save + save_view = BatchMediaSaveView() + result = save_view.save_single_item_with_vector_db_wait_safe( + item_data=item_data, + company_bot_id=company_bot_id, + user_profile=user_profile, + session_id=session_id, + bypass_similarity=bypass_similarity + ) + + return JsonResponse({ + 'success': True, + 'result': result + }) + + except Exception as e: + print(f"Unexpected error in retry save: {e}") + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=400) \ No newline at end of file diff --git a/chatbot/views/Media/status_views.py b/chatbot/views/Media/status_views.py new file mode 100644 index 0000000..f5deffd --- /dev/null +++ b/chatbot/views/Media/status_views.py @@ -0,0 +1,438 @@ +import logging +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse +from django.views import View +from chatbot.models import Tag, Profile, TagSourceChoices, TagChoices +import json +from chatbot.utils.knowledge_service.auto_tag_utils import TagProcessor +from chatbot.utils.knowledge_service.base_task_utils import determine_media_type_from_url +from chatbot.utils.knowledge_service.media_utils import get_media_type_from_ai_data, build_key_values + +logger = logging.getLogger('django') + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaTaskStatusView(View): + """API endpoint for checking Celery task status and updating data when complete""" + + def post(self, request): + try: + from celery.result import AsyncResult + + # Add logging for debugging + print(f"BatchMediaTaskStatusView - Request received") + print(f"Request body: {request.body[:500]}") # First 500 chars + + try: + data = json.loads(request.body) + except json.JSONDecodeError as e: + print(f"JSON decode error: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Invalid JSON: {str(e)}' + }, status=400) + + task_ids = data.get('task_ids', []) + print(f"Checking status for task IDs: {task_ids}") + + results = {} + for task_id in task_ids: + try: + task = AsyncResult(task_id) + print(f"Task {task_id} - Status: {task.status}, Ready: {task.ready()}") + + if task.ready(): + if task.successful(): + try: + ai_data = task.result + print(f"Task {task_id} successful, processing result") + print(f"Result type: {type(ai_data)}") + + # Check if result is None + if ai_data is None: + print(f"Warning: Task {task_id} returned None") + results[task_id] = { + 'status': 'ERROR', # CHANGED FROM SUCCESS TO ERROR + 'error': 'AI processing returned no data' + } + else: + processed_data = self.process_ai_extracted_data(ai_data) + results[task_id] = { + 'status': 'SUCCESS', + 'result': processed_data + } + except Exception as process_error: + print(f"Error processing task result for {task_id}: {process_error}") + import traceback + traceback.print_exc() + results[task_id] = { + 'status': 'ERROR', # ENSURE THIS IS ERROR NOT FAILURE + 'error': str(process_error) + } + else: + error_info = str(task.info) if task.info else 'Unknown error' + print(f"Task {task_id} failed: {error_info}") + results[task_id] = { + 'status': 'FAILURE', + 'error': error_info + } + else: + results[task_id] = { + 'status': 'PENDING' + } + except Exception as task_error: + print(f"Error checking task {task_id}: {task_error}") + import traceback + traceback.print_exc() + results[task_id] = { + 'status': 'ERROR', + 'error': str(task_error) + } + + print(f"Returning results for {len(results)} tasks") + return JsonResponse({ + 'success': True, + 'results': results + }) + + except Exception as e: + print(f"Unexpected error in BatchMediaTaskStatusView: {e}") + import traceback + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=500) + + + def is_excel_file(self, url_or_filename, media_type=None): + """Check if the file is an Excel file (.xlsx or .xls) by extension or media type""" + # Check by media type first (for Google Sheets and other sources) + if media_type: + excel_media_types = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'application/vnd.ms-excel', # .xls + 'application/vnd.google-apps.spreadsheet' # Google Sheets + ] + if media_type in excel_media_types: + return True + + # Check by file extension + if url_or_filename: + url_or_filename_lower = str(url_or_filename).lower() + if url_or_filename_lower.endswith('.xlsx') or url_or_filename_lower.endswith('.xls'): + return True + + return False + + def get_main_doc_media_type(self, ai_data): + if 'media_type' in ai_data and ai_data['media_type']: + return ai_data['media_type'] + + def validate_tags_against_database(self, tags, company=None): + """ + Filter tags to only include those that exist in the database. + """ + if not tags: + return [] + + # Extract tag texts + tag_texts = [] + for tag in tags: + if isinstance(tag, dict) and 'text' in tag: + tag_texts.append(tag['text']) + elif isinstance(tag, str): + tag_texts.append(tag) + + # Query database for existing tags + query = Tag.objects.filter( + name__in=tag_texts, + source_type__in=[TagSourceChoices.MANUAL, TagSourceChoices.AI_EXTRACTED], + status=TagChoices.APPROVED + ) + + # if company: + # query = query.filter(company=company) + + # Get set of valid tag names + valid_tag_names = set(query.values_list('name', flat=True)) + + # Filter original tags list + validated_tags = [] + for tag in tags: + tag_text = tag.get('text') if isinstance(tag, dict) else tag + if tag_text in valid_tag_names: + validated_tags.append(tag) + + return validated_tags + + def process_ai_extracted_data(self, ai_data, original_filename=None): + """Process AI extracted data into format expected by frontend""" + if not ai_data: + return { + 'auto_tags': [], + 'enhanced_data': None + } + + # *** SIMPLIFIED: Check if AI data is not a dictionary *** + if not isinstance(ai_data, dict): + error_msg = "AI processing failed - unable to extract structured data from document" + raise ValueError(error_msg) + + # Check for explicit error from AI processing + if ai_data.get('error') or ai_data.get('error_type'): + error_msg = ai_data.get('error', 'AI processing failed with unknown error') + print(f"AI extraction failed: {error_msg}") + raise ValueError(f"{error_msg}") + + def repair_structured_content(structured_content): + """Repair and validate structured content JSON""" + if not structured_content: + return {} + + # If it's already a dict, return as-is + if isinstance(structured_content, dict): + return structured_content + + # If it's a string, try to parse and repair + if isinstance(structured_content, str): + import json + try: + # Try direct JSON parsing first + return json.loads(structured_content) + except json.JSONDecodeError: + try: + # Use JSON repair if available + import json_repair + return json_repair.repair_json(structured_content) + except (ImportError, Exception) as e: + print(f"JSON repair failed for structured_content: {e}") + # Fallback: try to create a basic structure + try: + # Simple repair attempts + repaired = structured_content.strip() + if not repaired.startswith('{'): + repaired = '{' + repaired + if not repaired.endswith('}'): + repaired = repaired + '}' + return json.loads(repaired) + except: + print(f"All JSON repair attempts failed, returning empty dict") + return {} + + # Fallback for other types + return {} + + # Get user's company + company = None + company_name = None + if hasattr(self, 'request') and self.request.user.is_authenticated: + try: + user_profile = Profile.objects.get(email=self.request.user.email) + if user_profile.company: + company = user_profile.company + company_name = company.name + except Profile.DoesNotExist: + pass + + + def process_subdocument(subdoc_data): + """Recursively process subdocument data""" + if not isinstance(subdoc_data, dict): + logger.warning(f"Subdocument data is not a dictionary: {type(subdoc_data)}") + return None + + # Set organization to company name if empty + if not subdoc_data.get('organization'): + subdoc_data['organization'] = company_name or '' + + raw_tags = TagProcessor.extract_tag_texts(subdoc_data.get('tags', [])) + + tag_dicts = [{'text': tag, 'source': 'extracted'} for tag in raw_tags] + validated_tags = self.validate_tags_against_database(tag_dicts, company) + + validated_tag_texts = [tag['text'] for tag in validated_tags] + document_type = subdoc_data.get('document_type', '') + if isinstance(document_type, dict): + document_type_value = document_type.get('type', '') + document_type_value = document_type_value.title() if document_type_value else '' + else: + document_type_value = document_type.title() if document_type else '' + + key_values, array_metadata = build_key_values(subdoc_data) + subdoc_data['array_fields_metadata'] = array_metadata + + # Check if subdocument is Excel file - only set extracted_text for Excel files + subdoc_file_url = subdoc_data.get('file_url', '') + subdoc_urls = subdoc_data.get('url', []) + subdoc_url = subdoc_urls[0] if subdoc_urls else '' + subdoc_media_type = subdoc_data.get('media_type') + is_subdoc_excel = self.is_excel_file(subdoc_file_url, subdoc_media_type) or self.is_excel_file(subdoc_url, subdoc_media_type) + + processed = { + 'title': subdoc_data.get('title', ''), + 'summary': subdoc_data.get('summary', ''), + 'description': subdoc_data.get('summary', ''), + 'exact_content': subdoc_data.get('exact_content', ''), + 'extracted_text': subdoc_data.get('exact_content', '') if is_subdoc_excel else '', + 'organization': subdoc_data.get('organization', company_name or ''), + 'geography': to_title_case(subdoc_data.get('geography', '')), + 'document_type': document_type_value, + 'key_entities': subdoc_data.get('key_entities', []), + 'url': subdoc_data.get('url', []), + 'file_url': subdoc_data.get('file_url', ''), + 'source_document': subdoc_data.get('source_document', ''), + 'auto_tags': validated_tag_texts, + 'manual_tags': [], + 'key_values': key_values, + 'images': subdoc_data.get('images', []), + 'media_type': subdoc_data.get( + 'media_type', get_media_type_from_ai_data(subdoc_data.get('document_type', '')) + ), + 'error': subdoc_data.get('error') + } + + # Recursively process nested subdocuments + if subdoc_data.get('subdocument') and isinstance(subdoc_data['subdocument'], list): + processed['subdocument'] = [] + for nested_subdoc in subdoc_data['subdocument']: + nested_processed = process_subdocument(nested_subdoc) + if nested_processed: + processed['subdocument'].append(nested_processed) + + return processed + + document_type = ai_data.get('document_type', '') + if isinstance(document_type, dict): + document_type_value = document_type.get('type', '') + document_type_value = document_type_value.title() if document_type_value else '' + else: + document_type_value = document_type.title() if document_type else '' + + def to_title_case(text): + if not text: + return text + return str(text).strip().title() + + is_template = document_type_value.lower() == 'template' + original_filename = ai_data.get('original_filename') + repaired_structured_content = repair_structured_content(ai_data.get('structured_content')) + + def get_summary_text(data, structured_content): + for key in ('summary', 'description', 'abstract'): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + if isinstance(structured_content, dict): + for key, value in structured_content.items(): + if str(key).strip().lower() in ('summary', 'description', 'abstract'): + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, list): + values = [str(item).strip() for item in value if str(item).strip()] + if values: + return '\n'.join(values) + + return '' + + # Get media type first to check if it's Excel + media_type_value = self.get_main_doc_media_type(ai_data) + + # Check if main document is Excel file - only set extracted_text for Excel files + main_urls = ai_data.get('url', []) + main_url = main_urls[0] if main_urls else '' + is_main_excel = self.is_excel_file(original_filename, media_type_value) or self.is_excel_file(main_url, media_type_value) + print("AI DATA ------->", ai_data) + summary_text = get_summary_text(ai_data, repaired_structured_content) + main_data = { + 'title': original_filename if (original_filename and not is_template) else ai_data.get('title', ''), + 'summary': summary_text, + 'extracted_text': ai_data.get('exact_content', '') if is_main_excel else '', + 'organization': ai_data.get('organization', '') or company_name or '', + 'geography': to_title_case(ai_data.get('geography', '')), + 'document_type': document_type_value, + 'key_entities': ai_data.get('key_entities', []), + 'structured_content': repaired_structured_content, + 'url': ai_data.get('url', []), + 'source_documents': ai_data.get('source_document', []), + } + + # Process main tags + auto_tags = TagProcessor.process_tags(ai_data.get('tags', [])) + auto_tags = self.validate_tags_against_database(auto_tags, company) + + # Build enhanced key-values for main document + enhanced_key_values, array_fields_metadata = build_key_values(main_data) + main_data['array_fields_metadata'] = array_fields_metadata + + # Process subdocuments recursively + subdocuments = [] + if ai_data.get('subdocument') and isinstance(ai_data['subdocument'], list): + for subdoc in ai_data['subdocument']: + processed_subdoc = process_subdocument(subdoc) + if processed_subdoc: + subdocuments.append(processed_subdoc) + + # Process failed links + failed_links = [] + if ai_data.get('failed_links') and isinstance(ai_data['failed_links'], list): + for failed in ai_data['failed_links']: + processed_failed = process_subdocument(failed) + if processed_failed: + failed_links.append(processed_failed) + + # Process images + images = ai_data.get('images', []) if isinstance(ai_data.get('images'), list) else [] + print("ai_data: ", ai_data) + data = { + 'auto_tags': auto_tags, + 'enhanced_data': { + 'title': main_data['title'], + 'summary': main_data['summary'], + 'description': main_data['summary'], + 'extracted_text': main_data['extracted_text'], + 'organization': main_data['organization'], + 'enhanced_key_values': enhanced_key_values, + 'subdocument': subdocuments, + 'failed_links': failed_links, + 'images': images, + 'structured_content': repaired_structured_content, + 'url': ai_data.get('url', []), + 'source_documents': ai_data.get('source_document', []), + } + } + + if media_type_value: + data.get('enhanced_data', {})['media_type'] = media_type_value + + print("data: ", data) + return data + + +@method_decorator(staff_member_required, name='dispatch') +class VectorDBTaskStatusView(View): + """Check status of vector DB save task""" + + def post(self, request): + try: + from celery.result import AsyncResult + data = json.loads(request.body) + task_id = data.get('task_id') + + if not task_id: + return JsonResponse({'success': False, 'error': 'No task_id provided'}) + + task = AsyncResult(task_id) + + return JsonResponse({ + 'success': True, + 'status': task.status, + 'ready': task.ready(), + 'successful': task.successful() if task.ready() else None, + 'result': task.result if task.ready() else None + }) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}, status=400) diff --git a/chatbot/views/Media/upload_views.py b/chatbot/views/Media/upload_views.py new file mode 100644 index 0000000..2c2fa52 --- /dev/null +++ b/chatbot/views/Media/upload_views.py @@ -0,0 +1,85 @@ +from django.views.generic import TemplateView +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from chatbot.models import Tag, Profile, FileTypeChoices, TagSourceChoices, TagChoices, Company, EntityStatus +from chatbot.models.media_models import PriorityChoices +import json + + +@method_decorator(staff_member_required, name='dispatch') +class BatchMediaUploadView(TemplateView): + template_name = 'admin/batch_upload/batch_upload.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['media_types'] = FileTypeChoices.choices + context['priorities'] = PriorityChoices.choices + + extension_mapping = FileTypeChoices.get_extension_mapping() + context['file_types'] = [ + { + 'mime_type': choice[0], + 'label': choice[1], + 'extension': extension_mapping.get(choice[0], '') + } + for choice in FileTypeChoices.choices + ] + + from chatbot.models import CompanyBot + context['company_bots'] = CompanyBot.objects.all() + default_bot = CompanyBot.objects.filter(route='/tag_extractor') + if default_bot: + default_bot = default_bot.first() + context['default_bot_id'] = default_bot.id + + # Add companies for organization selection + context['companies'] = Company.objects.filter(status=EntityStatus.ACTIVE).order_by('name') + + # Add user's company info + user_company = None + if self.request.user.is_authenticated: + try: + user_profile = Profile.objects.get(email=self.request.user.email) + user_company = user_profile.company + context['user_company'] = user_company + except Profile.DoesNotExist: + pass + + try: + existing_tags_query = Tag.objects.filter( + source_type=TagSourceChoices.MANUAL, + status=TagChoices.APPROVED + ) + + context['existing_manual_tags'] = list( + existing_tags_query.values_list('name', flat=True).distinct().order_by('name') + ) + + document_types = [] + try: + tag_extractor_bot = CompanyBot.objects.filter(route='/tag_extractor').first() + if tag_extractor_bot and tag_extractor_bot.other_params: + try: + other_params = json.loads(tag_extractor_bot.other_params) if isinstance( + tag_extractor_bot.other_params, str + ) else tag_extractor_bot.other_params + + master_document_types = other_params.get('master_document_types', []) + if isinstance(master_document_types, list): + document_types = master_document_types + except (json.JSONDecodeError, TypeError): + pass + except Exception as e: + print(f"Error getting document types: {e}") + + if not document_types: + document_types = [] + + context['master_document_types'] = document_types + + except Exception as e: + print(f"Error getting context data: {e}") + context['existing_manual_tags'] = [] + context['master_document_types'] = [] + + return context diff --git a/chatbot/views/__init__.py b/chatbot/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatbot/views/admin/bot_admin_views.py b/chatbot/views/admin/bot_admin_views.py new file mode 100644 index 0000000..00503c4 --- /dev/null +++ b/chatbot/views/admin/bot_admin_views.py @@ -0,0 +1,307 @@ +""" +Custom Import/Export Views for CompanyBot with inline models +Add this to your views.py +""" +import json +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.forms import model_to_dict +from django.shortcuts import render, redirect +from django.http import HttpResponse +from django.db import transaction +from chatbot.models import CompanyBot, Voice, CompanyStateMachine, Company, Profile, ProfileType, BotVernacular +import logging + +logger = logging.getLogger('django') + +def generate_template(format_type): + """Generate empty template files for import""" + if format_type == 'json': + template_data = [{ + "name": "Example Bot", + "company_slug": "company-slug", + "context": "Bot context here", + "max_token": 1000, + "provider": "openai", + "bot_type": "qa", + "voices": [ + { + "type": "greeting", + "provider": "elevenlabs", + "name": "Voice Name", + "language": "en" + } + ], + "state_machines": [ + { + "name": "Step 1", + "step": 1, + "bot_question": "What is your name?", + "completion_criteria": "User provides name" + } + ] + }] + + response = HttpResponse( + json.dumps(template_data, indent=2), + content_type='application/json' + ) + response['Content-Disposition'] = 'attachment; filename="companybot_template.json"' + return response + + +@staff_member_required +def export_bots(request): + """Export CompanyBots with their inline models""" + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).first() + + # Check if this is a template request + is_template = request.GET.get('template', 'false').lower() == 'true' + + # Filter bots based on user permissions + if request.user.is_superuser: + bots = CompanyBot.objects.all() + elif profile and profile.profile_type == ProfileType.MODERATOR: + bots = CompanyBot.objects.filter(company=profile.company) + else: + messages.error(request, "You don't have permission to export bots.") + return redirect('admin:chatbot_companybot_changelist') + + # Get selected bot IDs if any + bot_ids = request.GET.get('ids', '').split(',') + bot_ids = [id for id in bot_ids if id] # Filter empty strings + if bot_ids: + bots = bots.filter(id__in=bot_ids) + + # If no format specified, show format selection page + export_format = request.GET.get('format') + if not export_format: + return render(request, 'admin/export_format.html', { + 'bot_count': bots.count(), + 'selected_ids': ','.join(bot_ids), + }) + + # Generate templates if requested + if is_template: + return generate_template(export_format) + + # Export based on format + if export_format == 'json': + bots = bots.select_related('company').prefetch_related( + 'voice_set', + 'companystatemachine_set', + 'bot_vernacular' + ) + return export_bots_json(bots) + else: + messages.error(request, "Invalid export format.") + return redirect('admin:chatbot_companybot_changelist') + + +def export_bots_json(bots): + """Export bots as JSON""" + data = [] + for bot in bots.select_related('company'): + + bot_data = model_to_dict(bot, exclude=['id', 'company', 'created_at', 'updated_at']) + bot_data['company_slug'] = bot.company.slug + # Add voices + voice_data=[] + voices = bot.voice_set.all() + for v in voices: + voice_data.append(model_to_dict(v, exclude=['id', 'company_bot', 'created_at', 'updated_at'])) + bot_data['voices'] = voice_data + + # Add state machines + state_machine_data=[] + state_machines = bot.companystatemachine_set.all().order_by('step') + for sm in state_machines: + sm_dict = model_to_dict( + sm, exclude=[ + 'id', 'company_bot', 'preprocess_bot', 'postprocess_bot', 'created_at', + 'updated_at', 'history' + ] + ) + sm_dict['preprocess_bot_route'] = ( + sm.preprocess_bot.route if sm.preprocess_bot else None + ) + sm_dict['postprocess_bot_route'] = ( + sm.postprocess_bot.route if sm.postprocess_bot else None + ) + + state_machine_data.append(sm_dict) + + bot_data['state_machines'] = state_machine_data + + bot_vernacular_data=[] + bot_vernaculars = bot.bot_vernacular.all().order_by('language') + for bv in bot_vernaculars: + bot_vernacular_data.append(model_to_dict(bv, exclude=[ + 'id', 'company_bot', 'created_at', 'updated_at', 'history' + ])) + + bot_data['bot_vernaculars'] = bot_vernacular_data + + data.append(bot_data) + + response = HttpResponse( + json.dumps(data, indent=2, default=str), + content_type='application/json' + ) + response['Content-Disposition'] = 'attachment; filename="company_bots.json"' + return response + + +@staff_member_required +def import_bots(request): + """Import CompanyBots with their inline models""" + if request.method == 'POST': + uploaded_file = request.FILES.get('import_file') + if not uploaded_file: + messages.error(request, "Please upload a file.") + return redirect('admin:chatbot_companybot_changelist') + + file_extension = uploaded_file.name.split('.')[-1].lower() + + try: + if file_extension == 'json': + result = import_bots_json(request, uploaded_file) + else: + messages.error(request, "Unsupported file format. Use JSON Only.") + return redirect('admin:chatbot_companybot_changelist') + + messages.success( + request, + f"Successfully imported {result['created']} bots and updated {result['updated']} bots." + ) + except Exception as e: + messages.error(request, f"Import failed: {str(e)}") + + return redirect('admin:chatbot_companybot_changelist') + + # GET request - show import form + return render(request, "admin/import_form.html") + + +def import_bots_json(request, uploaded_file): + """Import from JSON file""" + try: + data = json.load(uploaded_file) + user_email = request.user.email + profile = Profile.objects.filter(email=user_email).first() + + created_count = 0 + updated_count = 0 + bot_import_payloads = [] + + with transaction.atomic(): + # Pass 1: Create/update all bots and clear inline records. + for bot_data in data: + company_slug = bot_data.pop('company_slug') + try: + company = Company.objects.get(slug=company_slug) + except Company.DoesNotExist: + raise ValueError(f"Company with slug '{company_slug}' not found") + + # Check permissions + if not request.user.is_superuser: + if not profile or profile.profile_type != ProfileType.MODERATOR or profile.company != company: + raise PermissionError(f"You don't have permission to import bots for company {company_slug}") + + # Extract inline data + voices_data = bot_data.pop('voices', []) + state_machines_data = bot_data.pop('state_machines', []) + bot_vernacular_data = bot_data.pop('bot_vernaculars', []) + + # Create or update bot + bots = CompanyBot.objects.filter( + route=bot_data['route'], + company=company + ) + + if bots.exists(): + bot = bots.first() + for key, value in bot_data.items(): + setattr(bot, key, value) + bot.save() + updated_count += 1 + else: + bot = CompanyBot.objects.create( + company=company, + **bot_data + ) + created_count += 1 + + # Delete existing inline records + Voice.objects.filter(company_bot=bot).delete() + CompanyStateMachine.objects.filter(company_bot=bot).delete() + BotVernacular.objects.filter(company_bot=bot).delete() + + bot_import_payloads.append( + { + 'bot': bot, + 'company': company, + 'voices_data': voices_data, + 'state_machines_data': state_machines_data, + 'bot_vernacular_data': bot_vernacular_data, + } + ) + + # Pass 2: Create inline records once all bots are available. + for payload in bot_import_payloads: + bot = payload['bot'] + company = payload['company'] + voices_data = payload['voices_data'] + state_machines_data = payload['state_machines_data'] + bot_vernacular_data = payload['bot_vernacular_data'] + + # Create voices + for voice_data in voices_data: + Voice.objects.create(company_bot=bot, **voice_data) + + # Create state machines + for sm_data in state_machines_data: + sm_payload = sm_data.copy() + + # Handle bot references + preprocess_bot_route = sm_payload.pop('preprocess_bot_route', None) + postprocess_bot_route = sm_payload.pop('postprocess_bot_route', None) + + preprocess_bot = None + if preprocess_bot_route: + preprocess_bot = CompanyBot.objects.filter( + route=preprocess_bot_route, company=company + ).first() + if not preprocess_bot: + raise ValueError( + f"Preprocess bot route '{preprocess_bot_route}' not found for " + f"company '{company.slug}' while importing bot '{bot.route}'." + ) + + postprocess_bot = None + if postprocess_bot_route: + postprocess_bot = CompanyBot.objects.filter( + route=postprocess_bot_route, company=company + ).first() + if not postprocess_bot: + raise ValueError( + f"Postprocess bot route '{postprocess_bot_route}' not found for " + f"company '{company.slug}' while importing bot '{bot.route}'." + ) + + CompanyStateMachine.objects.create( + company_bot=bot, + preprocess_bot=preprocess_bot, + postprocess_bot=postprocess_bot, + **sm_payload + ) + # Create bot_vernacular + for bv_data in bot_vernacular_data: + BotVernacular.objects.create(company_bot=bot, **bv_data) + + return {'created': created_count, 'updated': updated_count} + + except Exception as e: + logger.error(f"Error importing bots: {e}", exc_info=True) + return {'created': 0, 'updated': 0} diff --git a/chatbot/views/admin/generic_upload_views.py b/chatbot/views/admin/generic_upload_views.py new file mode 100644 index 0000000..8c28800 --- /dev/null +++ b/chatbot/views/admin/generic_upload_views.py @@ -0,0 +1,473 @@ +import json +import csv +from io import StringIO +from django.apps import apps +from django.views.generic import TemplateView +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse, HttpResponse +from django.views import View +from django.db import transaction +from django.core.exceptions import ValidationError +from django.utils.text import capfirst +from django.core.serializers.json import DjangoJSONEncoder + + +@method_decorator(staff_member_required, name='dispatch') +class GenericBatchUploadView(TemplateView): + """Generic batch upload view that works with any model""" + template_name = 'admin/generic_batch_upload.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Get model from URL parameters + app_label = self.kwargs.get('app_label') + model_name = self.kwargs.get('model_name') + + try: + model = apps.get_model(app_label, model_name) + except LookupError: + context['error'] = f"Model {app_label}.{model_name} not found" + return context + + # Get model metadata + model_data = self.get_model_metadata(model) + + context['model_data'] = json.dumps(model_data, cls=DjangoJSONEncoder) + context['model_name'] = model._meta.model_name + context['model_verbose_name'] = model._meta.verbose_name_plural + context['back_url'] = f"/admin/{app_label}/{model_name}/" + context['template_url'] = f"/admin/{app_label}/{model_name}/batch-template/" + context['import_url'] = f"/admin/{app_label}/{model_name}/batch-import/" + + return context + + def get_model_metadata(self, model): + """Extract model field metadata for the frontend""" + fields = [] + + # Fields to always exclude + excluded_fields = [ + 'id', 'created_at', 'updated_at', 'created', 'modified', + 'created_by', 'updated_by', 'deleted_at', 'is_deleted' + ] + + for field in model._meta.get_fields(): + # Skip auto-generated and excluded fields + if field.auto_created or field.name in excluded_fields: + continue + + # Skip reverse relations + if field.many_to_many and not field.concrete: + continue + + # Convert verbose_name to string to avoid JSON serialization issues + verbose_name = str(field.verbose_name) + + field_info = { + 'name': field.name, + 'verbose_name': capfirst(verbose_name), + 'type': field.get_internal_type(), + 'required': not field.blank and not field.null, + 'max_length': getattr(field, 'max_length', None), + 'default_selected': not field.blank, # Select non-blank fields by default + 'help_text': str(getattr(field, 'help_text', '')) + } + + # Handle choices - convert to serializable format + if hasattr(field, 'choices') and field.choices: + field_info['choices'] = [ + [str(choice[0]), str(choice[1])] + for choice in field.choices + ] + else: + field_info['choices'] = None + + # Handle foreign keys + if field.many_to_one or field.one_to_one: + field_info['type'] = 'ForeignKey' + field_info['related_model'] = field.related_model._meta.label + + # Handle many-to-many + if field.many_to_many: + field_info['type'] = 'ManyToManyField' + field_info['related_model'] = field.related_model._meta.label + + fields.append(field_info) + + return { + 'model': model._meta.label, + 'fields': fields + } + + +@method_decorator(staff_member_required, name='dispatch') +class GenericBatchTemplateView(View): + """Generate CSV template for batch upload""" + + def post(self, request, app_label, model_name): + try: + data = json.loads(request.body) + fields = data.get('fields', []) + + if not fields: + return JsonResponse({'error': 'No fields selected'}, status=400) + + # Get model + model = apps.get_model(app_label, model_name) + + # Create CSV + output = StringIO() + writer = csv.writer(output) + + # Write headers + headers = [] + for field_name in fields: + try: + field = model._meta.get_field(field_name) + headers.append(field_name) + except: + headers.append(field_name) + + writer.writerow(headers) + + # Add a sample row with field descriptions + sample_row = [] + for field_name in fields: + try: + field = model._meta.get_field(field_name) + + if field.choices: + # Show available choices with both value and display name + choices_str = '|'.join([f'{choice[0]}' for choice in field.choices]) + sample_row.append(f'[Options: {choices_str}]') + elif field.get_internal_type() == 'DateField': + sample_row.append('YYYY-MM-DD') + elif field.get_internal_type() == 'DateTimeField': + sample_row.append('YYYY-MM-DD HH:MM:SS') + elif field.get_internal_type() == 'BooleanField': + sample_row.append('true|false') + elif field.get_internal_type() == 'IntegerField': + sample_row.append('123') + elif field.get_internal_type() == 'EmailField': + sample_row.append('email@example.com') + else: + sample_row.append(f'Sample {field.verbose_name}') + except: + sample_row.append('Sample value') + + writer.writerow(sample_row) + + # Create response + response = HttpResponse(output.getvalue(), content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{model_name}_template.csv"' + + return response + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + +@method_decorator(staff_member_required, name='dispatch') +class GenericBatchImportView(View): + """Process batch import of data with optimizations""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fk_cache = {} # Cache for foreign key lookups + self.m2m_cache = {} # Cache for many-to-many lookups + + def post(self, request, app_label, model_name): + try: + data = json.loads(request.body) + model_label = data.get('model') + rows = data.get('data', []) + + if not rows: + return JsonResponse({'error': 'No data provided'}, status=400) + + # Get model + model = apps.get_model(app_label, model_name) + + # Clear caches for this batch + self.fk_cache = {} + self.m2m_cache = {} + + # Pre-load foreign keys for optimization + self.preload_foreign_keys(model, rows) + + results = [] + total_rows = len(rows) + + # Process each row + with transaction.atomic(): + for index, row_data in enumerate(rows): + try: + # Process the row + result = self.process_row(model, row_data, request.user) + result['progress'] = f"{index + 1}/{total_rows}" + results.append(result) + except Exception as e: + results.append({ + 'success': False, + 'message': str(e), + 'row_index': index, + 'progress': f"{index + 1}/{total_rows}" + }) + + # Calculate summary + success_count = sum(1 for r in results if r['success']) + error_count = len(results) - success_count + + return JsonResponse({ + 'success': True, + 'results': results, + 'summary': { + 'total': len(results), + 'success': success_count, + 'errors': error_count + } + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + def preload_foreign_keys(self, model, rows): + """Pre-load foreign keys to reduce queries""" + fk_fields = {} + + # Identify FK fields and collect values + for field in model._meta.fields: + if field.many_to_one or field.one_to_one: + fk_fields[field.name] = { + 'field': field, + 'values': set() + } + + # Collect all FK values from rows + for row in rows: + for field_name, field_info in fk_fields.items(): + if field_name in row and row[field_name]: + field_info['values'].add(str(row[field_name])) + + # Batch load FKs + for field_name, field_info in fk_fields.items(): + if field_info['values']: + related_model = field_info['field'].related_model + + # Try loading by PK first + pk_values = [] + for val in field_info['values']: + try: + pk_values.append(int(val)) + except (ValueError, TypeError): + pass + + if pk_values: + for obj in related_model.objects.filter(pk__in=pk_values): + cache_key = f"{related_model._meta.label}:{obj.pk}" + self.fk_cache[cache_key] = obj + + # Try loading by name for remaining values + if hasattr(related_model, 'name'): + name_values = [v for v in field_info['values'] + if f"{related_model._meta.label}:{v}" not in self.fk_cache] + if name_values: + for obj in related_model.objects.filter(name__in=name_values): + cache_key = f"{related_model._meta.label}:{obj.name}" + self.fk_cache[cache_key] = obj + + def process_row(self, model, row_data, user): + """Process a single row of data with caching""" + try: + # Clean empty strings + cleaned_data = {} + + for field_name, value in row_data.items(): + if value == '': + # Check if field can be null/blank + try: + field = model._meta.get_field(field_name) + if field.null: + cleaned_data[field_name] = None + elif field.blank: + cleaned_data[field_name] = '' + # Skip if field doesn't allow empty values + except: + pass + else: + cleaned_data[field_name] = self.convert_field_value(model, field_name, value) + + # Handle foreign keys with caching + for field_name, value in list(cleaned_data.items()): + try: + field = model._meta.get_field(field_name) + + if field.many_to_one or field.one_to_one: + if value: + related_model = field.related_model + cache_key = f"{related_model._meta.label}:{value}" + + # Check cache first + if cache_key in self.fk_cache: + cleaned_data[field_name] = self.fk_cache[cache_key] + else: + # If not in cache, try to fetch (shouldn't happen with preloading) + try: + obj = related_model.objects.get(pk=value) + self.fk_cache[cache_key] = obj + cleaned_data[field_name] = obj + except related_model.DoesNotExist: + if hasattr(related_model, 'name'): + try: + obj = related_model.objects.get(name=value) + self.fk_cache[cache_key] = obj + cleaned_data[field_name] = obj + except related_model.DoesNotExist: + raise ValidationError(f"{field_name}: Related object not found") + else: + raise ValidationError(f"{field_name}: Related object not found") + + elif field.many_to_many: + # Handle many-to-many separately after object creation + m2m_value = cleaned_data.pop(field_name) + # Store for later processing + cleaned_data[f'_m2m_{field_name}'] = m2m_value + + except Exception as e: + if field_name in cleaned_data: + # Keep the value for now, let model validation handle it + pass + + # Create object + obj = model(**{k: v for k, v in cleaned_data.items() if not k.startswith('_m2m_')}) + + # Handle special fields before validation + if hasattr(obj, 'created_by'): + try: + field = model._meta.get_field('created_by') + related_model = field.related_model + + # If created_by expects a specific model (like Profile) + if related_model: + # Try to get the related instance based on the user + if hasattr(related_model, 'objects'): + # Common patterns: email, user, username + if hasattr(user, 'email'): + try: + obj.created_by = related_model.objects.get(email=user.email) + except: + try: + obj.created_by = related_model.objects.get(user=user) + except: + pass + except: + # If anything fails, just skip setting created_by + pass + + # Handle other auto-set fields + if hasattr(obj, 'source_type') and not getattr(obj, 'source_type', None): + # Try to find MANUAL choice + try: + field = model._meta.get_field('source_type') + if hasattr(field, 'choices'): + for choice_value, choice_display in field.choices: + if 'MANUAL' in str(choice_value).upper(): + obj.source_type = choice_value + break + except: + pass + + if hasattr(obj, 'company') and not getattr(obj, 'company', None): + # Try to set company from created_by profile + if hasattr(obj, 'created_by') and obj.created_by: + if hasattr(obj.created_by, 'company'): + obj.company = obj.created_by.company + + # Validate + obj.full_clean() + + # Save + obj.save() + + # Handle many-to-many fields with batch loading + for key, value in cleaned_data.items(): + if key.startswith('_m2m_'): + field_name = key[5:] # Remove '_m2m_' prefix + if value: + # Assume comma-separated IDs or names + values = [v.strip() for v in str(value).split(',')] + field = model._meta.get_field(field_name) + related_model = field.related_model + + # Collect PK and name values + pk_values = [] + name_values = [] + + for val in values: + try: + pk_values.append(int(val)) + except (ValueError, TypeError): + name_values.append(val) + + # Batch load related objects + related_objects = [] + + if pk_values: + related_objects.extend( + related_model.objects.filter(pk__in=pk_values) + ) + + if name_values and hasattr(related_model, 'name'): + related_objects.extend( + related_model.objects.filter(name__in=name_values) + ) + + getattr(obj, field_name).set(related_objects) + + return { + 'success': True, + 'message': f'Created {model._meta.verbose_name} successfully', + 'object_id': obj.pk + } + + except ValidationError as e: + return { + 'success': False, + 'message': f'Validation error: {e.message_dict if hasattr(e, "message_dict") else str(e)}' + } + except Exception as e: + return { + 'success': False, + 'message': f'Error: {str(e)}' + } + + def convert_field_value(self, model, field_name, value): + """Convert string value to appropriate field type""" + try: + field = model._meta.get_field(field_name) + + if field.get_internal_type() == 'BooleanField': + return value.lower() in ['true', '1', 'yes', 'on'] + elif field.get_internal_type() == 'IntegerField': + return int(value) if value else None + elif field.get_internal_type() == 'FloatField': + return float(value) if value else None + elif field.get_internal_type() == 'DecimalField': + from decimal import Decimal + return Decimal(value) if value else None + elif field.get_internal_type() in ['DateField', 'DateTimeField']: + if not value: + return None + # Handle various date formats + from django.utils.dateparse import parse_date, parse_datetime + if field.get_internal_type() == 'DateTimeField': + return parse_datetime(value) + else: + return parse_date(value) + + return value + + except: + return value diff --git a/chatbot/views/admin/media_upload_views.py b/chatbot/views/admin/media_upload_views.py new file mode 100644 index 0000000..5f22ee2 --- /dev/null +++ b/chatbot/views/admin/media_upload_views.py @@ -0,0 +1,2419 @@ +# import logging +# import traceback +# from pathlib import Path +# +# import requests +# from django.views.generic import TemplateView +# from django.contrib.admin.views.decorators import staff_member_required +# from django.utils.decorators import method_decorator +# from django.http import JsonResponse +# from django.views import View +# from chatbot.models import Media, Tag, KeyValue, Profile, FileTypeChoices, CompanyBot, TagSourceChoices, TagChoices, \ +# Company, EntityStatus, FileDisplayMode +# from chatbot.models.media_models import PriorityChoices, MediaImage, MediaTypeChoices +# import json +# import tempfile, os +# import uuid +# from django.core.cache import cache +# from chatbot.celery_tasks.knowledge_service.tag_tasks import get_auto_extracted_data +# from chatbot.utils.knowledge_service.base_task_utils import determine_media_type_from_url +# from chatbot.utils.knowledge_service.duplicate_detector import DuplicateDetector +# from django.core.files.base import ContentFile +# import base64 +# from django.utils.text import slugify +# from django.conf import settings +# +# BOT_PROFILE_ID = 1 +# ENABLE_SIMILARITY_CHECK = getattr(settings, 'BATCH_UPLOAD_ENABLE_SIMILARITY_CHECK', False) +# CACHE_TIMEOUT = getattr(settings, 'BATCH_UPLOAD_CACHE_TIMEOUT', 7200) +# logger = logging.getLogger('django') +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaUploadView(TemplateView): +# template_name = 'admin/batch_upload.html' +# +# def get_context_data(self, **kwargs): +# context = super().get_context_data(**kwargs) +# context['media_types'] = FileTypeChoices.choices +# context['priorities'] = PriorityChoices.choices +# +# extension_mapping = FileTypeChoices.get_extension_mapping() +# context['file_types'] = [ +# { +# 'mime_type': choice[0], +# 'label': choice[1], +# 'extension': extension_mapping.get(choice[0], '') +# } +# for choice in FileTypeChoices.choices +# ] +# +# from chatbot.models import CompanyBot +# context['company_bots'] = CompanyBot.objects.all() +# default_bot = CompanyBot.objects.filter(route='/tag_extractor') +# if default_bot: +# default_bot = default_bot.first() +# context['default_bot_id'] = default_bot.id +# +# # Add companies for organization selection +# context['companies'] = Company.objects.filter(status=EntityStatus.ACTIVE).order_by('name') +# +# # Add user's company info +# user_company = None +# if self.request.user.is_authenticated: +# try: +# user_profile = Profile.objects.get(email=self.request.user.email) +# user_company = user_profile.company +# context['user_company'] = user_company +# except Profile.DoesNotExist: +# pass +# +# try: +# existing_tags_query = Tag.objects.filter( +# source_type=TagSourceChoices.MANUAL, +# status=TagChoices.APPROVED +# ) +# +# context['existing_manual_tags'] = list( +# existing_tags_query.values_list('name', flat=True).distinct().order_by('name') +# ) +# +# document_types = [] +# try: +# tag_extractor_bot = CompanyBot.objects.filter(route='/tag_extractor').first() +# if tag_extractor_bot and tag_extractor_bot.other_params: +# try: +# other_params = json.loads(tag_extractor_bot.other_params) if isinstance( +# tag_extractor_bot.other_params, str +# ) else tag_extractor_bot.other_params +# +# master_document_types = other_params.get('master_document_types', []) +# if isinstance(master_document_types, list): +# document_types = master_document_types +# except (json.JSONDecodeError, TypeError): +# pass +# except Exception as e: +# print(f"Error getting document types: {e}") +# +# if not document_types: +# document_types = [] +# +# context['master_document_types'] = document_types +# +# except Exception as e: +# print(f"Error getting context data: {e}") +# context['existing_manual_tags'] = [] +# context['master_document_types'] = [] +# +# return context +# +# +# class CacheManager: +# """Centralized cache management for batch upload""" +# +# @staticmethod +# def get_cache_key(session_id, item_type, item_id): +# """Generate consistent cache keys with proper sanitization""" +# import re +# +# # Sanitize all components to ensure memcached compatibility +# sanitized_session_id = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(session_id)) +# sanitized_item_type = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(item_type)) +# sanitized_item_id = re.sub(r'[^a-zA-Z0-9\-_.]', '_', str(item_id)) +# +# # Remove multiple consecutive underscores +# sanitized_session_id = re.sub(r'_+', '_', sanitized_session_id) +# sanitized_item_type = re.sub(r'_+', '_', sanitized_item_type) +# sanitized_item_id = re.sub(r'_+', '_', sanitized_item_id) +# +# # Generate the cache key +# cache_key = f"batch_upload_{sanitized_session_id}_{sanitized_item_type}_{sanitized_item_id}" +# +# # Final length check +# if len(cache_key) > 240: +# import hashlib +# key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest() +# cache_key = f"batch_upload_{sanitized_session_id}_{sanitized_item_type}_{key_hash[:16]}" +# +# # Final sanitization pass +# cache_key = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key) +# +# return cache_key +# +# @staticmethod +# def cache_file(file, session_id, file_index): +# """Cache uploaded file content with sanitized cache key""" +# try: +# import re +# import hashlib +# +# file_content = b'' +# for chunk in file.chunks(): +# file_content += chunk +# +# # More aggressive sanitization for memcached compatibility +# # Remove all non-alphanumeric characters except dots, hyphens, underscores +# sanitized_name = re.sub(r'[^a-zA-Z0-9\-_.]', '_', file.name) +# # Remove multiple consecutive underscores +# sanitized_name = re.sub(r'_+', '_', sanitized_name) +# # Remove leading/trailing underscores +# sanitized_name = sanitized_name.strip('_') +# # Ensure reasonable length (memcached has 250 char limit for keys) +# if len(sanitized_name) > 30: +# # Keep first 30 chars and add hash of full name for uniqueness +# name_hash = hashlib.md5(file.name.encode('utf-8')).hexdigest()[:8] +# sanitized_name = sanitized_name[:22] + '_' + name_hash +# +# # Generate cache key with additional validation +# cache_key_suffix = f"{file_index}_{sanitized_name}" +# # Ensure the final cache key component doesn't have problematic characters +# cache_key_suffix = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key_suffix) +# +# cache_key = CacheManager.get_cache_key(session_id, 'file', cache_key_suffix) +# +# # Additional validation: ensure cache key is memcached compatible +# # Total length should be under 250 chars and contain only safe characters +# if len(cache_key) > 240: # Leave some buffer +# # If still too long, use a hash-based approach +# key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest() +# cache_key = f"batch_upload_{session_id}_file_{file_index}_{key_hash[:16]}" +# +# # Final validation - ensure only safe characters +# cache_key = re.sub(r'[^a-zA-Z0-9\-_.]', '_', cache_key) +# +# cache_data = { +# 'content': file_content, +# 'name': file.name, # Keep original name +# 'size': file.size, +# 'type': 'file', +# 'file_index': file_index +# } +# +# cache.set(cache_key, cache_data, timeout=CACHE_TIMEOUT) +# print(f"Cached file: {cache_key} (original: {file.name})") +# return cache_key +# except Exception as e: +# print(f"Error caching file {file.name}: {e}") +# import traceback +# traceback.print_exc() +# return None +# +# @staticmethod +# def cache_subdocument(subdoc_data, session_id, parent_index, subdoc_path): +# """Cache subdocument data for retry purposes - with all fields""" +# try: +# cache_key = CacheManager.get_cache_key(session_id, 'subdoc', f"{parent_index}_{subdoc_path}") +# +# # Ensure all subdocument fields are included +# complete_subdoc_data = { +# 'title': subdoc_data.get('title', ''), +# 'summary': subdoc_data.get('summary', ''), +# 'description': subdoc_data.get('description', subdoc_data.get('summary', '')), +# 'media_type': subdoc_data.get('media_type', FileTypeChoices.TXT.value), +# 'priority': subdoc_data.get('priority', 'P1'), +# 'extracted_text': subdoc_data.get('extracted_text', subdoc_data.get('exact_content', '')), +# 'exact_content': subdoc_data.get('exact_content', ''), +# 'organization': subdoc_data.get('organization', ''), +# 'document_type': subdoc_data.get('document_type', ''), +# 'key_entities': subdoc_data.get('key_entities', []), +# 'manual_tags': subdoc_data.get('manual_tags', []), +# 'auto_tags': subdoc_data.get('auto_tags', []), +# 'tags': subdoc_data.get('tags', []), +# 'key_values': subdoc_data.get('key_values', []), +# 'images': subdoc_data.get('images', []), +# 'subdocument': subdoc_data.get('subdocument', []), +# 'url': subdoc_data.get('url', []) +# } +# +# cache_data = { +# 'data': complete_subdoc_data, +# 'parent_index': parent_index, +# 'path': subdoc_path, +# 'type': 'subdocument' +# } +# +# cache.set(cache_key, cache_data, timeout=CACHE_TIMEOUT) +# print(f"Cached subdocument: {cache_key} with data: {complete_subdoc_data.get('title', 'No title')}") +# return cache_key +# except Exception as e: +# print(f"Error caching subdocument: {e}") +# traceback.print_exc() +# return None +# +# @staticmethod +# def get_cached_item(cache_key): +# """Retrieve item from cache with Redis-specific debugging""" +# import time +# from django.core.cache import cache +# +# max_retries = 2 +# retry_delay = 0.1 +# +# for attempt in range(max_retries + 1): +# try: +# # Add timing to detect slow Redis responses +# start_time = time.time() +# cached_data = cache.get(cache_key) +# response_time = time.time() - start_time +# +# if cached_data: +# print(f"✓ Cache HIT: {cache_key} (attempt {attempt + 1}, {response_time:.3f}s)") +# return cached_data +# else: +# print(f"✗ Cache MISS: {cache_key} (attempt {attempt + 1}, {response_time:.3f}s)") +# +# # For Redis, try to get connection info +# try: +# from django.core.cache import cache +# if hasattr(cache, '_cache') and hasattr(cache._cache, 'get_client'): +# redis_client = cache._cache.get_client() +# connection_info = redis_client.connection_pool.connection_kwargs +# print(f"Redis connection: {connection_info.get('host')}:{connection_info.get('port')}") +# +# # Check Redis connection +# redis_client.ping() +# print("Redis ping successful") +# +# # Check if key actually exists +# exists = redis_client.exists(cache_key) +# print(f"Redis key exists check: {exists}") +# +# except Exception as redis_debug_error: +# print(f"Redis debug error: {redis_debug_error}") +# +# # If not last attempt, wait and retry +# if attempt < max_retries: +# print(f"Retrying cache get in {retry_delay}s...") +# time.sleep(retry_delay) +# retry_delay *= 2 # Exponential backoff +# continue +# else: +# return None +# +# except Exception as e: +# print(f"Cache retrieval error for {cache_key} (attempt {attempt + 1}): {e}") +# if attempt < max_retries: +# time.sleep(retry_delay) +# retry_delay *= 2 +# continue +# else: +# return None +# +# return None +# +# @staticmethod +# def extend_cache_timeout(cache_keys, additional_timeout=None): +# """Extend cache timeout for failed items""" +# timeout = additional_timeout or CACHE_TIMEOUT +# for cache_key in cache_keys: +# cached_item = cache.get(cache_key) +# if cached_item: +# cache.set(cache_key, cached_item, timeout=timeout) +# print(f"Extended cache timeout for: {cache_key}") +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class GetCachedItemView(View): +# """API endpoint to retrieve cached items""" +# +# def post(self, request): +# try: +# self._source_doc_cache = {} +# data = json.loads(request.body) +# company_bot_id = data.get('company_bot_id') +# media_items = data.get('items', []) +# session_id = data.get('session_id') +# +# results = [] +# stats = { +# 'total': len(media_items), +# 'successful': 0, +# 'failed': 0, +# 'partial_success': 0, +# 'timeouts': 0, +# 'similarity_failures': 0 +# } +# +# # Get current user's profile +# try: +# user_profile = Profile.objects.get(email=request.user.email) +# except Profile.DoesNotExist: +# user_profile = None +# +# print(f"Starting batch save for {len(media_items)} files") +# +# # Process each file with fault tolerance +# for i, item_data in enumerate(media_items): +# filename = item_data.get('filename', f'File_{i}') +# print(f"Processing file {i + 1}/{len(media_items)}: {filename}") +# +# try: +# bypass_similarity = item_data.get('bypass_similarity', False) +# +# # CRITICAL FIX: Use the file_index from item_data, not the loop index +# # The file_index in item_data corresponds to the actual index used during caching +# actual_file_index = item_data.get('file_index', i) +# print(f"Using file_index {actual_file_index} for {filename} (loop index: {i})") +# +# # Ensure the item_data has the correct file_index for cache lookup +# item_data['file_index'] = actual_file_index +# +# result = self.save_single_item_with_vector_db_wait_safe( +# item_data=item_data, +# company_bot_id=company_bot_id, +# user_profile=user_profile, +# session_id=session_id, +# bypass_similarity=bypass_similarity +# ) +# +# # Track statistics +# if result['success']: +# stats['successful'] += 1 +# else: +# stats['failed'] += 1 +# if result.get('partial_success'): +# stats['partial_success'] += 1 +# if result.get('error_type') in ['VECTOR_DB_TIMEOUT', 'WAIT_ERROR']: +# stats['timeouts'] += 1 +# if result.get('error_type') == 'SIMILARITY_CHECK_FAILED': +# stats['similarity_failures'] += 1 +# +# results.append(result) +# print( +# f"File {i + 1} result: {'✓' if result['success'] else '✗'} - {result.get('message', 'No message')}") +# +# except Exception as item_error: +# print(f"Critical error processing {filename}: {item_error}") +# stats['failed'] += 1 +# +# # Use the actual file_index for error reporting too +# actual_file_index = item_data.get('file_index', i) +# +# results.append({ +# 'success': False, +# 'filename': filename, +# 'message': f'Critical processing error: {str(item_error)}', +# 'error_type': 'CRITICAL_ERROR', +# 'file_index': actual_file_index, +# 'file_key': item_data.get('file_key'), +# 'session_id': session_id, +# 'vector_db_saved': False +# }) +# +# # Preserve cache for failed files +# failed_cache_keys = [] +# for r in results: +# if not r['success'] and r.get('file_key'): +# failed_cache_keys.append(r['file_key']) +# # Also preserve cache for failed subdocuments +# if r.get('subdocument_results'): +# for subdoc_result in r['subdocument_results']: +# if not subdoc_result.get('success') and subdoc_result.get('cache_key'): +# failed_cache_keys.append(subdoc_result['cache_key']) +# +# if failed_cache_keys: +# CacheManager.extend_cache_timeout(failed_cache_keys) +# +# # Generate summary message +# summary_message = self.generate_batch_summary(stats) +# print(f"Batch complete: {summary_message}") +# +# return JsonResponse({ +# 'success': True, +# 'results': results, +# 'stats': stats, +# 'summary_message': summary_message, +# 'session_id': session_id +# }) +# +# except json.JSONDecodeError: +# return JsonResponse({ +# 'success': False, +# 'error': 'Invalid JSON data' +# }, status=400) +# except Exception as batch_error: +# print(f"Batch processing error: {batch_error}") +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': f'Batch processing failed: {str(batch_error)}' +# }, status=500) +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaExtractView(View): +# """API endpoint for extracting data from uploaded files""" +# +# def post(self, request): +# try: +# import re +# import time +# +# files = request.FILES.getlist('files') +# company_bot_id = request.POST.get('company_bot_id') +# session_id = request.POST.get('session_id') +# +# # Get file indices if provided +# file_indices = request.POST.getlist('file_indices') +# +# extracted_data = [] +# +# # Generate session ID if not provided +# if not session_id: +# session_id = str(uuid.uuid4()) +# +# company_bot = None +# if company_bot_id: +# try: +# company_bot = CompanyBot.objects.get(id=company_bot_id) +# except CompanyBot.DoesNotExist: +# pass +# +# print(f"Processing {len(files)} files with indices: {file_indices}") +# +# for i, file in enumerate(files): +# try: +# # Use provided file index or generate unique one +# if i < len(file_indices) and file_indices[i]: +# file_index = int(file_indices[i]) +# else: +# # Generate unique index if not provided +# file_index = int(time.time() * 1000000) + i +# +# print(f"Processing file {i}: {file.name} with index {file_index}") +# +# # Store file for retry purposes with sanitized cache key +# file_key = CacheManager.cache_file(file, session_id, file_index) +# +# data = self.extract_file_data( +# file=file, +# company_bot=company_bot, +# file_index=file_index, # Use unique index +# request=request +# ) +# if data.get('error') or data.get('error_type'): +# raise Exception(data.get('error', 'AI extraction failed')) +# +# data['status'] = 'success' +# data['error'] = None +# data['session_id'] = session_id +# data['file_key'] = file_key +# +# print(f"Successfully processed file {file.name}, cache key: {file_key}") +# +# except Exception as e: +# print(f"Error processing file {file.name}: {e}") +# +# # For failed extractions, still cache the file +# if i < len(file_indices) and file_indices[i]: +# file_index = int(file_indices[i]) +# else: +# file_index = int(time.time() * 1000000) + i +# +# file_key = CacheManager.cache_file(file, session_id, file_index) +# +# data = { +# 'filename': file.name, +# 'status': 'error', +# 'error': str(e), +# 'file_index': file_index, +# 'session_id': session_id, +# 'file_key': file_key, +# 'name': file.name, +# 'media_type': self.get_media_type(file.name), +# 'description': f'Extracted from {file.name}', +# 'extracted_text': '', +# 'priority': 'P1', +# 'tags': [], +# 'manual_tags': [], +# 'auto_tags': [], +# 'auto_tag_task_id': None, +# 'auto_tags_ready': True, +# 'key_values': [], +# 'subdocument': [], +# 'images': [] +# } +# +# extracted_data.append(data) +# +# print(f"Completed processing {len(extracted_data)} files") +# +# return JsonResponse({ +# 'success': True, +# 'data': extracted_data, +# 'session_id': session_id +# }) +# +# except Exception as e: +# print(f"BatchMediaExtractView.post() error: {e}") +# import traceback +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': str(e) +# }, status=400) +# +# def extract_file_data(self, file, company_bot, file_index, request=None): +# """Extract data from file and start async AI extraction""" +# file_extension = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else None +# +# if file_extension and not FileTypeChoices.is_valid_extension(file_extension): +# raise ValueError(f"Unsupported file format: .{file_extension}") +# +# max_file_size_mb = 50 +# if company_bot and hasattr(company_bot, 'other_params') and company_bot.other_params: +# try: +# other_params = json.loads(company_bot.other_params) if isinstance( +# company_bot.other_params, str +# ) else company_bot.other_params +# max_file_size_mb = other_params.get('max_file_size_mb', 50) +# except: +# pass +# +# max_file_size_bytes = max_file_size_mb * 1024 * 1024 +# +# if file.size > max_file_size_bytes: +# file_size_mb = file.size / (1024 * 1024) +# raise ValueError( +# f"File size ({file_size_mb:.2f} MB) exceeds the maximum allowed size of {max_file_size_mb} MB. " +# f"Please reduce the file size.") +# +# # Save file temporarily +# with tempfile.NamedTemporaryFile(delete=False, suffix=f".{file_extension}") as tmp: +# for chunk in file.chunks(): +# tmp.write(chunk) +# tmp_path = tmp.name +# +# user_profile = None +# company = None +# company_name = '' +# if request and request.user.is_authenticated: +# try: +# user_profile = Profile.objects.get(email=request.user.email) +# company = user_profile.company +# if company: +# company_name = company.name +# except Profile.DoesNotExist: +# pass +# +# master_tags = get_master_tags( +# company=company, other_params=company_bot.other_params if company_bot else None +# ) +# print("Sending master tags: ", master_tags) +# base_name = file.name.rsplit('.', 1)[0] if '.' in file.name else file.name +# +# other_data = { +# "master_tag": master_tags, +# "original_filename": base_name +# } +# +# # Start async task (non-blocking) +# print(f"Starting async extraction task for {file.name}") +# task = get_auto_extracted_data.delay( +# file_path=tmp_path, +# company_bot_id=company_bot.id if company_bot else None, +# file_extension=file_extension, +# other_data=other_data +# ) +# base_name = file.name.rsplit('.', 1)[0] if '.' in file.name else file.name +# +# return { +# 'filename': file.name, +# 'file_index': file_index, +# 'name': base_name, +# 'media_type': self.get_media_type(file.name), +# 'description': f'Extracted from {file.name}', +# 'extracted_text': 'AI extraction in progress...', +# 'priority': 'P1', +# 'tags': [], +# 'manual_tags': [], +# 'auto_tags': [], +# 'auto_tag_task_id': task.id, +# 'auto_tags_ready': False, +# 'key_values': [], +# 'subdocument': [], +# 'images': [], +# 'failed_links': [], +# 'organization': company_name, +# 'company_name': company_name +# } +# +# def get_media_type(self, filename): +# """Map file extension to media type using FileTypeChoices""" +# ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else None +# return FileTypeChoices.get_mime_from_extension(ext) if ext else FileTypeChoices.TXT.value +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaRetryExtractView(View): +# """API endpoint for retrying extraction of a single file""" +# +# def post(self, request): +# try: +# data = json.loads(request.body) +# file_data = data.get('file_data') +# company_bot_id = data.get('company_bot_id') +# session_id = data.get('session_id') +# +# if not file_data: +# return JsonResponse({ +# 'success': False, +# 'error': 'No file data provided' +# }, status=400) +# +# company_bot = None +# if company_bot_id: +# try: +# company_bot = CompanyBot.objects.get(id=company_bot_id) +# except CompanyBot.DoesNotExist: +# pass +# +# # Try to retrieve stored file +# file_key = file_data.get('file_key') +# stored_file = None +# +# if file_key: +# stored_file = CacheManager.get_cached_item(file_key) +# +# if not stored_file: +# return JsonResponse({ +# 'success': False, +# 'error': 'Original file data not found. Please re-upload the file or try uploading again.' +# }, status=400) +# +# # Create a file-like object from stored data +# class StoredFile: +# def __init__(self, stored_data): +# self.name = stored_data['name'] +# self.size = stored_data['size'] +# self._content = stored_data['content'] +# +# def chunks(self): +# chunk_size = 8192 +# for i in range(0, len(self._content), chunk_size): +# yield self._content[i:i + chunk_size] +# +# try: +# stored_file_obj = StoredFile(stored_file) +# extract_view = BatchMediaExtractView() +# extracted_data = extract_view.extract_file_data( +# file=stored_file_obj, +# company_bot=company_bot, +# file_index=file_data.get('file_index', 0), +# request=request +# ) +# extracted_data['status'] = 'success' +# extracted_data['error'] = None +# extracted_data['session_id'] = session_id +# extracted_data['file_key'] = file_key +# +# return JsonResponse({ +# 'success': True, +# 'data': extracted_data +# }) +# except Exception as e: +# return JsonResponse({ +# 'success': False, +# 'error': str(e) +# }) +# +# except json.JSONDecodeError: +# return JsonResponse({ +# 'success': False, +# 'error': 'Invalid JSON data' +# }, status=400) +# except Exception as e: +# return JsonResponse({ +# 'success': False, +# 'error': f'Unexpected error: {str(e)}' +# }, status=400) +# +# +# # Shared helper functions +# def get_media_type_from_ai_data(document_type): +# """Map AI-detected document type to our media type choices""" +# if isinstance(document_type, dict): +# doc_type_text = document_type.get('type', '') +# else: +# doc_type_text = document_type or '' +# +# if not doc_type_text: +# return FileTypeChoices.TXT.value +# +# if doc_type_text and doc_type_text != '': +# doc_type_text = doc_type_text.lower() +# type_mapping = { +# 'report': FileTypeChoices.PDF, +# 'spreadsheet': FileTypeChoices.XLSX, +# 'document': FileTypeChoices.DOCX, +# 'text': FileTypeChoices.TXT, +# 'csv': FileTypeChoices.CSV, +# 'excel': FileTypeChoices.XLSX, +# 'word': FileTypeChoices.DOCX, +# 'pdf': FileTypeChoices.PDF +# } +# +# for key, value in type_mapping.items(): +# if key in doc_type_text: +# return value.value +# return FileTypeChoices.TXT.value +# +# +# def process_tags(tags_data): +# """Process tags into consistent format""" +# processed_tags = [] +# for tag in tags_data: +# if isinstance(tag, dict): +# processed_tags.append(tag) +# else: +# processed_tags.append({'text': tag, 'source': 'extracted'}) +# return processed_tags +# +# +# def get_master_tags(company=None, other_params=None, include_description=False): +# try: +# if other_params: +# try: +# if isinstance(other_params, str): +# params = json.loads(other_params) +# else: +# params = other_params +# +# include_description = params.get('include_description', include_description) +# except (json.JSONDecodeError, TypeError): +# pass +# +# query = Tag.objects.filter( +# source_type__in=[TagSourceChoices.MANUAL, TagSourceChoices.AI_EXTRACTED], +# status=TagChoices.APPROVED +# ) +# +# # if company: +# # query = query.filter(company=company) +# +# if include_description: +# return [ +# { +# 'name': tag['name'], +# 'description': tag['description'] or '' +# } +# for tag in query.values('name', 'description').distinct() +# ] +# else: +# return list(query.values_list('name', flat=True).distinct()) +# +# except Exception as e: +# print(f"Error getting master tags: {e}") +# return [] +# +# +# def extract_tag_texts(tags_data): +# """Extract just the text from tags for subdocuments""" +# texts = [] +# for tag in tags_data: +# if isinstance(tag, dict) and 'text' in tag: +# texts.append(tag['text']) +# elif isinstance(tag, str): +# texts.append(tag) +# return texts +# +# +# def build_key_values(data_dict): +# """Build key-value pairs from document data with metadata tracking""" +# key_values = [] +# array_fields_metadata = [] # Track which fields were originally arrays +# +# if data_dict.get('title'): +# key_values.append({'key': 'TITLE', 'value': str(data_dict['title']), 'source': 'ai'}) +# +# organization_value = data_dict.get('organization', '') +# key_values.append({'key': 'ORGANIZATION', 'value': str(organization_value), 'source': 'ai'}) +# +# # ADD GEOGRAPHY HANDLING +# geography_value = data_dict.get('geography', '') +# if geography_value: +# key_values.append({'key': 'GEOGRAPHY', 'value': str(geography_value), 'source': 'ai'}) +# +# document_type = data_dict.get('document_type') +# if document_type: +# if isinstance(document_type, dict): +# doc_type_value = document_type.get('type', '') +# if doc_type_value: +# doc_type_value = doc_type_value.title() +# key_values.append({'key': 'DOCUMENT TYPE', 'value': str(doc_type_value), 'source': 'ai'}) +# else: +# doc_type_value = document_type.title() if document_type else '' +# key_values.append({'key': 'DOCUMENT TYPE', 'value': str(doc_type_value), 'source': 'ai'}) +# +# if data_dict.get('key_entities') and len(data_dict['key_entities']) > 0: +# key_values.append({'key': 'KEY ENTITIES', 'value': ', '.join(map(str, data_dict['key_entities'])), 'source': 'ai'}) +# +# # ENHANCED: Handle structured content with proper array formatting +# if data_dict.get('structured_content') and isinstance(data_dict['structured_content'], dict): +# for heading, content in data_dict['structured_content'].items(): +# if heading.upper() in [ +# 'BASIC INFORMATION', 'GENERAL INFORMATION', 'TAGS', 'KEYWORDS', +# 'CATEGORIES', 'CLASSIFICATION', 'TAGS FOR CLASSIFICATION' +# ]: +# continue +# +# key_name = heading.upper() +# +# # Format arrays as multi-line strings with bullet points +# if isinstance(content, list): +# # Track that this field was originally an array +# array_fields_metadata.append(key_name) +# +# # Ensure all list items are strings +# string_items = [str(item) for item in content if item is not None] +# formatted_content = '\n'.join([f"• {item}" for item in string_items]) +# key_values.append({ +# 'key': key_name, +# 'value': formatted_content, +# 'original_type': 'array', +# 'source': 'ai' # Mark as AI-extracted +# }) +# else: +# # Handle text that might already be formatted +# content_str = str(content) if content is not None else '' +# key_values.append({ +# 'key': key_name, +# 'value': content_str, +# 'original_type': 'string', +# 'source': 'ai' # Mark as AI-extracted +# }) +# +# return key_values, array_fields_metadata +# +# +# def process_formatted_content_backend(content): +# """Process content to maintain bullet point formatting""" +# if not content or not isinstance(content, str): +# return content +# +# # Check if content has bullet points +# if '•' in content or content.count('\n') > 0: +# lines = content.split('\n') +# processed_lines = [] +# +# for line in lines: +# line = line.strip() +# if line: +# # Ensure bullet point formatting +# if not line.startswith('•') and not line.startswith('-') and not line.startswith('*'): +# if len(lines) > 1: # Multi-line content should have bullets +# line = f"• {line}" +# elif line.startswith('-') or line.startswith('*'): +# # Convert other bullet styles to • +# line = f"• {line[1:].strip()}" +# processed_lines.append(line) +# +# return '\n'.join(processed_lines) +# +# return content +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaTaskStatusView(View): +# """API endpoint for checking Celery task status and updating data when complete""" +# +# def post(self, request): +# try: +# from celery.result import AsyncResult +# +# # Add logging for debugging +# print(f"BatchMediaTaskStatusView - Request received") +# print(f"Request body: {request.body[:500]}") # First 500 chars +# +# try: +# data = json.loads(request.body) +# except json.JSONDecodeError as e: +# print(f"JSON decode error: {e}") +# return JsonResponse({ +# 'success': False, +# 'error': f'Invalid JSON: {str(e)}' +# }, status=400) +# +# task_ids = data.get('task_ids', []) +# print(f"Checking status for task IDs: {task_ids}") +# +# results = {} +# for task_id in task_ids: +# try: +# task = AsyncResult(task_id) +# print(f"Task {task_id} - Status: {task.status}, Ready: {task.ready()}") +# +# if task.ready(): +# if task.successful(): +# try: +# ai_data = task.result +# print(f"Task {task_id} successful, processing result") +# print(f"Result type: {type(ai_data)}") +# +# # Check if result is None +# if ai_data is None: +# print(f"Warning: Task {task_id} returned None") +# results[task_id] = { +# 'status': 'ERROR', # CHANGED FROM SUCCESS TO ERROR +# 'error': 'AI processing returned no data' +# } +# else: +# processed_data = self.process_ai_extracted_data(ai_data) +# results[task_id] = { +# 'status': 'SUCCESS', +# 'result': processed_data +# } +# except Exception as process_error: +# print(f"Error processing task result for {task_id}: {process_error}") +# import traceback +# traceback.print_exc() +# results[task_id] = { +# 'status': 'ERROR', # ENSURE THIS IS ERROR NOT FAILURE +# 'error': str(process_error) +# } +# else: +# error_info = str(task.info) if task.info else 'Unknown error' +# print(f"Task {task_id} failed: {error_info}") +# results[task_id] = { +# 'status': 'FAILURE', +# 'error': error_info +# } +# else: +# results[task_id] = { +# 'status': 'PENDING' +# } +# except Exception as task_error: +# print(f"Error checking task {task_id}: {task_error}") +# import traceback +# traceback.print_exc() +# results[task_id] = { +# 'status': 'ERROR', +# 'error': str(task_error) +# } +# +# print(f"Returning results for {len(results)} tasks") +# return JsonResponse({ +# 'success': True, +# 'results': results +# }) +# +# except Exception as e: +# print(f"Unexpected error in BatchMediaTaskStatusView: {e}") +# import traceback +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': str(e) +# }, status=500) +# +# +# def get_main_doc_media_type(self, ai_data): +# """Get the correct document type for main document based on linked file, skipping failed URLs. +# If only one failed URL exists and it's the same as the only URL, still consider it. +# """ +# media_type = None +# file_urls = ai_data.get('url', []) +# failed_links = ai_data.get('failed_links', []) +# +# # Collect failed URLs from failed_links +# failed_urls = set() +# if failed_links and isinstance(failed_links, list): +# for item in failed_links: +# file_url = item.get('file_url') +# if file_url: +# failed_urls.add(file_url) +# +# # Handle edge case: if only one URL and it’s the same as the single failed URL → allow it +# if ( +# len(file_urls) == 1 +# and len(failed_urls) == 1 +# and next(iter(failed_urls)) == file_urls[0] +# ): +# valid_urls = file_urls +# else: +# # Otherwise, filter out failed URLs +# valid_urls = [url for url in file_urls if url not in failed_urls] +# +# # Proceed only if we have at least one valid URL +# if valid_urls: +# source_doc_url = valid_urls[0] +# media_type, filename, response = determine_media_type_from_url(source_doc_url, parent_media=None) +# +# return media_type +# +# +# def validate_tags_against_database(self, tags, company=None): +# """ +# Filter tags to only include those that exist in the database. +# """ +# if not tags: +# return [] +# +# # Extract tag texts +# tag_texts = [] +# for tag in tags: +# if isinstance(tag, dict) and 'text' in tag: +# tag_texts.append(tag['text']) +# elif isinstance(tag, str): +# tag_texts.append(tag) +# +# # Query database for existing tags +# query = Tag.objects.filter( +# name__in=tag_texts, +# source_type__in=[TagSourceChoices.MANUAL, TagSourceChoices.AI_EXTRACTED], +# status=TagChoices.APPROVED +# ) +# +# # if company: +# # query = query.filter(company=company) +# +# # Get set of valid tag names +# valid_tag_names = set(query.values_list('name', flat=True)) +# +# # Filter original tags list +# validated_tags = [] +# for tag in tags: +# tag_text = tag.get('text') if isinstance(tag, dict) else tag +# if tag_text in valid_tag_names: +# validated_tags.append(tag) +# +# return validated_tags +# +# def process_ai_extracted_data(self, ai_data, original_filename=None): +# """Process AI extracted data into format expected by frontend""" +# if not ai_data: +# return { +# 'auto_tags': [], +# 'enhanced_data': None +# } +# +# # *** SIMPLIFIED: Check if AI data is not a dictionary *** +# if not isinstance(ai_data, dict): +# error_msg = "AI processing failed - unable to extract structured data from document" +# raise ValueError(error_msg) +# +# # Check for explicit error from AI processing +# if ai_data.get('error') or ai_data.get('error_type'): +# error_msg = ai_data.get('error', 'AI processing failed with unknown error') +# print(f"AI extraction failed: {error_msg}") +# raise ValueError(f"{error_msg}") +# +# def repair_structured_content(structured_content): +# """Repair and validate structured content JSON""" +# if not structured_content: +# return {} +# +# # If it's already a dict, return as-is +# if isinstance(structured_content, dict): +# return structured_content +# +# # If it's a string, try to parse and repair +# if isinstance(structured_content, str): +# import json +# try: +# # Try direct JSON parsing first +# return json.loads(structured_content) +# except json.JSONDecodeError: +# try: +# # Use JSON repair if available +# import json_repair +# return json_repair.repair_json(structured_content) +# except (ImportError, Exception) as e: +# print(f"JSON repair failed for structured_content: {e}") +# # Fallback: try to create a basic structure +# try: +# # Simple repair attempts +# repaired = structured_content.strip() +# if not repaired.startswith('{'): +# repaired = '{' + repaired +# if not repaired.endswith('}'): +# repaired = repaired + '}' +# return json.loads(repaired) +# except: +# print(f"All JSON repair attempts failed, returning empty dict") +# return {} +# +# # Fallback for other types +# return {} +# +# # Get user's company +# company = None +# company_name = None +# if hasattr(self, 'request') and self.request.user.is_authenticated: +# try: +# user_profile = Profile.objects.get(email=self.request.user.email) +# if user_profile.company: +# company = user_profile.company +# company_name = company.name +# except Profile.DoesNotExist: +# pass +# +# +# def process_subdocument(subdoc_data): +# """Recursively process subdocument data""" +# if not isinstance(subdoc_data, dict): +# logger.warning(f"Subdocument data is not a dictionary: {type(subdoc_data)}") +# return None +# +# # Set organization to company name if empty +# if not subdoc_data.get('organization'): +# subdoc_data['organization'] = company_name or '' +# +# raw_tags = extract_tag_texts(subdoc_data.get('tags', [])) +# +# tag_dicts = [{'text': tag, 'source': 'extracted'} for tag in raw_tags] +# validated_tags = self.validate_tags_against_database(tag_dicts, company) +# +# validated_tag_texts = [tag['text'] for tag in validated_tags] +# document_type = subdoc_data.get('document_type', '') +# if isinstance(document_type, dict): +# document_type_value = document_type.get('type', '') +# document_type_value = document_type_value.title() if document_type_value else '' +# else: +# document_type_value = document_type.title() if document_type else '' +# +# key_values, array_metadata = build_key_values(subdoc_data) +# subdoc_data['array_fields_metadata'] = array_metadata +# +# processed = { +# 'title': subdoc_data.get('title', ''), +# 'summary': subdoc_data.get('summary', ''), +# 'description': subdoc_data.get('summary', ''), +# 'exact_content': subdoc_data.get('exact_content', ''), +# 'extracted_text': subdoc_data.get('exact_content', ''), +# 'organization': subdoc_data.get('organization', company_name or ''), +# 'geography': to_title_case(subdoc_data.get('geography', '')), +# 'document_type': document_type_value, +# 'key_entities': subdoc_data.get('key_entities', []), +# 'url': subdoc_data.get('url', []), +# 'file_url': subdoc_data.get('file_url', ''), +# 'source_document': subdoc_data.get('source_document', ''), +# 'auto_tags': validated_tag_texts, +# 'manual_tags': [], +# 'key_values': key_values, +# 'images': subdoc_data.get('images', []), +# 'media_type': subdoc_data.get( +# 'media_type', get_media_type_from_ai_data(subdoc_data.get('document_type', '')) +# ), +# 'error': subdoc_data.get('error') +# } +# +# # Recursively process nested subdocuments +# if subdoc_data.get('subdocument') and isinstance(subdoc_data['subdocument'], list): +# processed['subdocument'] = [] +# for nested_subdoc in subdoc_data['subdocument']: +# nested_processed = process_subdocument(nested_subdoc) +# if nested_processed: +# processed['subdocument'].append(nested_processed) +# +# return processed +# +# document_type = ai_data.get('document_type', '') +# if isinstance(document_type, dict): +# document_type_value = document_type.get('type', '') +# document_type_value = document_type_value.title() if document_type_value else '' +# else: +# document_type_value = document_type.title() if document_type else '' +# +# def to_title_case(text): +# if not text: +# return text +# return str(text).strip().title() +# +# is_template = document_type_value.lower() == 'template' +# original_filename = ai_data.get('original_filename') +# repaired_structured_content = repair_structured_content(ai_data.get('structured_content')) +# +# main_data = { +# 'title': original_filename if (original_filename and not is_template) else ai_data.get('title', ''), +# 'summary': ai_data.get('summary', ''), +# 'extracted_text': ai_data.get('exact_content', '') or ai_data.get('summary', ''), +# 'organization': ai_data.get('organization', '') or company_name or '', +# 'geography': to_title_case(ai_data.get('geography', '')), +# 'document_type': document_type_value, +# 'key_entities': ai_data.get('key_entities', []), +# 'structured_content': repaired_structured_content, +# 'url': ai_data.get('url', []) +# } +# +# # Process main tags +# auto_tags = process_tags(ai_data.get('tags', [])) +# auto_tags = self.validate_tags_against_database(auto_tags, company) +# +# # Build enhanced key-values for main document +# enhanced_key_values, array_fields_metadata = build_key_values(main_data) +# media_type_value = self.get_main_doc_media_type(ai_data) +# main_data['array_fields_metadata'] = array_fields_metadata +# +# # Process subdocuments recursively +# subdocuments = [] +# if ai_data.get('subdocument') and isinstance(ai_data['subdocument'], list): +# for subdoc in ai_data['subdocument']: +# processed_subdoc = process_subdocument(subdoc) +# if processed_subdoc: +# subdocuments.append(processed_subdoc) +# +# # Process failed links +# failed_links = [] +# if ai_data.get('failed_links') and isinstance(ai_data['failed_links'], list): +# for failed in ai_data['failed_links']: +# processed_failed = process_subdocument(failed) +# if processed_failed: +# failed_links.append(processed_failed) +# +# # Process images +# images = ai_data.get('images', []) if isinstance(ai_data.get('images'), list) else [] +# print("ai_data: ", ai_data) +# data = { +# 'auto_tags': auto_tags, +# 'enhanced_data': { +# 'description': main_data['summary'], +# 'extracted_text': main_data['extracted_text'], +# 'organization': main_data['organization'], +# 'enhanced_key_values': enhanced_key_values, +# 'subdocument': subdocuments, +# 'failed_links': failed_links, +# 'images': images, +# 'structured_content': repaired_structured_content, +# 'url': ai_data.get('url', []) +# } +# } +# +# if media_type_value: +# data.get('enhanced_data', {})['media_type'] = media_type_value +# +# print("data: ", data) +# return data +# +# +# # Helper class for shared tag processing logic +# class TagProcessor: +# @staticmethod +# def process_tags_for_media(tag_names, tag_source, user_profile, company, is_manual=True): +# """Process tags and create/update tag objects""" +# tags = [] +# +# for tag_name in tag_names: +# if isinstance(tag_name, dict): +# tag_text = tag_name.get('text', '') +# source = tag_name.get('source', tag_source) +# description = tag_name.get('description', '') +# else: +# tag_text = tag_name +# source = tag_source +# description = '' +# +# # Clean tag name +# if tag_text.startswith('auto-'): +# clean_tag_name = tag_text.replace('auto-', '') +# else: +# clean_tag_name = tag_text +# +# if is_manual: +# tag, created = Tag.objects.get_or_create( +# name=clean_tag_name, +# defaults={ +# 'created_by': user_profile, +# 'company': company, +# 'status': TagChoices.APPROVED, +# 'source_type': TagSourceChoices.MANUAL, +# 'description': '' +# } +# ) +# if not created and tag.source_type == TagSourceChoices.MANUAL: +# tag.status = TagChoices.APPROVED +# tag.save() +# else: +# # Auto tags +# if source == 'extracted': +# source_type = TagSourceChoices.AI_EXTRACTED +# status = TagChoices.APPROVED +# desc_to_save = '' +# else: +# source_type = TagSourceChoices.AI_GENERATED +# status = TagChoices.PENDING +# desc_to_save = description +# +# tag, created = Tag.objects.get_or_create( +# name=clean_tag_name, +# defaults={ +# 'created_by_id': BOT_PROFILE_ID, +# 'company': company, +# 'status': status, +# 'source_type': source_type, +# 'description': desc_to_save +# } +# ) +# +# tags.append(tag) +# +# return tags +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaSaveView(View): +# """API endpoint for saving batch media data with fault tolerance""" +# +# def clean_text_to_title_case(self, text): +# """Convert text to title case, handling common edge cases""" +# if not text: +# return text +# +# # Convert to string and strip whitespace +# text = str(text).strip() +# +# # Handle acronyms and special cases +# words = text.split() +# cleaned_words = [] +# +# for word in words: +# # Keep acronyms (all caps) as is +# if word.isupper() and len(word) > 1: +# cleaned_words.append(word) +# else: +# # Convert to title case +# cleaned_words.append(word.title()) +# +# return ' '.join(cleaned_words) +# +# +# def get_or_create_source_document_media(self, source_doc_url, parent_media, company_bot_id, user_profile, +# company_slug): +# """ +# Download and save source document as a Media object if not already saved. +# Returns the Media object for the source document. +# """ +# # Use a class-level cache to track saved source documents within this batch +# if not hasattr(self, '_source_doc_cache'): +# self._source_doc_cache = {} +# +# # Check if we've already processed this source document +# if source_doc_url in self._source_doc_cache: +# return self._source_doc_cache[source_doc_url] +# +# try: +# # Use the separated function to determine media type and filename +# media_type, filename, response = determine_media_type_from_url(source_doc_url, parent_media) +# +# if not media_type or not filename: +# print(f"media_type: {media_type} and filename: {filename}") +# print(f"Error creating source document media for {source_doc_url}: Media type or file name is null.") +# return None +# print(f"Final filename: {filename}, media_type: {media_type}") +# +# # Create Media object for source document +# source_media = Media( +# name=filename, +# media_type=media_type, +# priority=parent_media.priority, +# company_bot_id=company_bot_id, +# parent=parent_media, +# organization=parent_media.organization, +# display_mode=FileDisplayMode.PRIVATE +# ) +# +# # Save the file +# source_media.file.save(filename, ContentFile(response.content), save=False) +# source_media.save() +# +# # Add reference to original URL +# KeyValue.objects.create( +# media=source_media, +# key='ORIGINAL_URL', +# value=source_doc_url +# ) +# +# KeyValue.objects.create( +# media=source_media, +# key='DOCUMENT_TYPE', +# value='Source Document' +# ) +# +# # Cache the result +# self._source_doc_cache[source_doc_url] = source_media +# +# print(f"Created source document media: {source_media.id} - {source_media.name}") +# return source_media +# +# except Exception as e: +# print(f"Error creating source document media for {source_doc_url}: {e}") +# return None +# +# +# def wait_for_vector_db_save_safe(self, task_id, timeout=30): +# """Enhanced waiting with better error handling""" +# import time +# from celery.result import AsyncResult +# +# try: +# intervals = [0.1, 0.2, 0.5, 1.0, 2.0, 3.0] +# start_time = time.time() +# attempt = 0 +# +# while time.time() - start_time < timeout: +# try: +# task = AsyncResult(task_id) +# if task.ready(): +# if task.successful(): +# return { +# 'completed': True, +# 'successful': True, +# 'result': task.result, +# 'wait_time': time.time() - start_time +# } +# else: +# return { +# 'completed': True, +# 'successful': False, +# 'result': f'Vector DB task failed: {task.info}', +# 'wait_time': time.time() - start_time, +# 'error_type': 'VECTOR_DB_TASK_FAILED' +# } +# except Exception as poll_error: +# print(f"Polling error for task {task_id}: {poll_error}") +# +# sleep_time = intervals[min(attempt, len(intervals) - 1)] +# time.sleep(sleep_time) +# attempt += 1 +# +# return { +# 'completed': False, +# 'successful': False, +# 'result': f'Vector DB save timeout after {timeout}s', +# 'wait_time': timeout, +# 'error_type': 'VECTOR_DB_TIMEOUT' +# } +# +# except Exception as wait_error: +# return { +# 'completed': False, +# 'successful': False, +# 'result': f'Wait error: {str(wait_error)}', +# 'error_type': 'WAIT_ERROR' +# } +# +# def save_single_item_with_vector_db_wait_safe(self, item_data, company_bot_id, user_profile, session_id, +# bypass_similarity=False): +# """Save a single media item with comprehensive error handling""" +# file_key = item_data.get('file_key') +# filename = item_data.get('filename', 'Unknown') +# file_index = item_data.get('file_index') +# +# # if "fail" in filename.lower(): +# # print(f"Forced save failure for {filename}") +# # raise ValueError(f"Forced save failure for {filename}") +# +# try: +# company_bot = CompanyBot.objects.get(id=company_bot_id) +# selected_company = None +# if item_data.get('organization_slug'): +# try: +# selected_company = Company.objects.get(slug=item_data['organization_slug']) +# except Company.DoesNotExist: +# pass +# +# if not selected_company and user_profile: +# selected_company = user_profile.company +# +# if not selected_company: +# selected_company = company_bot.company +# +# if selected_company: +# company_slug = selected_company.slug +# else: +# company_slug = company_bot.company.slug +# extracted_text = item_data.get('extracted_text', '') +# +# # Step 1: Similarity check +# if ENABLE_SIMILARITY_CHECK and not bypass_similarity: +# try: +# DuplicateDetector.check_for_duplicates( +# extracted_text=extracted_text, +# company_slug=company_slug, +# trigram_threshold=0, +# semantic_threshold=0.85, +# trigram_exact_threshold=0.90, +# semantic_exact_threshold=0.9 +# ) +# except Exception as similarity_error: +# return { +# 'success': False, +# 'filename': filename, +# 'message': f'Similarity check failed: {str(similarity_error)}', +# 'error_type': 'SIMILARITY_CHECK_FAILED', +# 'file_index': file_index, +# 'file_key': file_key, +# 'session_id': session_id, +# 'vector_db_saved': False +# } +# +# # Step 2: Retrieve file from cache +# file_content = None +# file_name = None +# +# if file_key: +# cached_file = CacheManager.get_cached_item(file_key) +# if cached_file: +# file_content = cached_file.get('content') +# file_name = cached_file.get('name') +# else: +# return { +# 'success': False, +# 'filename': filename, +# 'message': 'File not found in cache for saving', +# 'error_type': 'FILE_NOT_FOUND_IN_CACHE', +# 'file_index': file_index, +# 'file_key': file_key, +# 'session_id': session_id, +# 'vector_db_saved': False +# } +# +# # Step 3: Create and save media +# try: +# organization_instance = None +# if item_data.get('organization_slug'): +# try: +# organization_instance = Company.objects.get(slug=item_data['organization_slug']) +# except Company.DoesNotExist: +# print(f"Warning: Company with slug {item_data['organization_slug']} not found") +# +# media = Media( +# name=item_data['name'], +# media_type=item_data['media_type'], +# priority=item_data['priority'], +# description=item_data['description'], +# company_bot_id=company_bot_id, +# organization=organization_instance, +# ) +# +# if file_content and file_name: +# from django.core.files.base import ContentFile +# media.file.save(file_name, ContentFile(file_content), save=False) +# +# # Save and get the vector DB task ID +# vector_task_id = media.save(company_slug=company_slug) +# +# except Exception as media_save_error: +# return { +# 'success': False, +# 'filename': filename, +# 'message': f'Media save failed: {str(media_save_error)}', +# 'error_type': 'MEDIA_SAVE_FAILED', +# 'file_index': file_index, +# 'file_key': file_key, +# 'session_id': session_id, +# 'vector_db_saved': False +# } +# +# # Step 4: Process tags and key-values with prioritized company +# try: +# all_tags = [] +# +# # Process manual tags with selected company (from dropdown priority) +# manual_tags = TagProcessor.process_tags_for_media( +# item_data.get('manual_tags', []), +# 'manual', +# user_profile, +# selected_company, +# is_manual=True +# ) +# all_tags.extend(manual_tags) +# +# # Process auto tags with selected company (from dropdown priority) +# auto_tags = TagProcessor.process_tags_for_media( +# item_data.get('auto_tags', []), +# 'extracted', +# user_profile, +# selected_company, +# is_manual=False +# ) +# all_tags.extend(auto_tags) +# +# if all_tags: +# media.tags.set(all_tags) +# +# # Key-value pairs - ensure organization is saved +# org_found = False +# print("item_data: ", item_data) +# for kv in item_data.get('key_values', []): +# KeyValue.objects.create( +# media=media, +# key=kv['key'], +# value=kv['value'] +# ) +# +# except Exception as tag_kv_error: +# print(f"Warning: Tag/KV processing failed for {filename}: {tag_kv_error}") +# +# # Step 5: Wait for vector DB save +# vector_result = {'successful': True, 'result': 'No vector task'} +# if vector_task_id: +# vector_result = self.wait_for_vector_db_save_safe(vector_task_id) +# +# if not vector_result['successful']: +# print(f"Vector DB save failed for media {media.id}: {vector_result['result']}") +# return { +# 'success': False, +# 'filename': filename, +# 'media_id': media.id, +# 'message': f"Saved to database but vector DB failed: {vector_result['result']}", +# 'error_type': vector_result.get('error_type', 'VECTOR_DB_FAILED'), +# 'file_index': file_index, +# 'file_key': file_key, +# 'session_id': session_id, +# 'vector_db_saved': False, +# 'partial_success': True, +# 'vector_task_id': vector_task_id, +# 'subdocument_results': [], +# 'image_results': [] +# } +# +# # Step 5.5: Process source documents if no subdocuments but URLs exist +# source_document_results = [] +# if not item_data.get('subdocument') and item_data.get('url'): +# print(f"Processing source documents for main document without subdocuments") +# +# for source_url in item_data.get('url', []): +# try: +# source_media = self.get_or_create_source_document_media( +# source_url, +# media, +# company_bot_id, +# user_profile, +# company_slug +# ) +# if source_media: +# source_document_results.append({ +# 'success': True, +# 'source_media_id': source_media.id, +# 'source_url': source_url, +# 'title': source_media.name +# }) +# print(f"Saved source document: {source_media.name} (ID: {source_media.id})") +# else: +# source_document_results.append({ +# 'success': False, +# 'error': f'Failed to create source document for {source_url}', +# 'source_url': source_url +# }) +# except Exception as source_error: +# print(f"Error creating source document for {source_url}: {source_error}") +# source_document_results.append({ +# 'success': False, +# 'error': str(source_error), +# 'source_url': source_url +# }) +# +# # Step 6: Process subdocuments recursively +# subdocument_results = [] +# if item_data.get('subdocument'): +# # Cache subdocuments before processing +# self._cache_subdocuments_recursive( +# item_data['subdocument'], +# session_id, +# file_index, +# "" +# ) +# +# subdoc_results = self.process_subdocuments_recursive( +# item_data['subdocument'], +# media, +# company_bot_id, +# user_profile, +# company_slug, +# session_id, +# file_index, +# "" +# ) +# subdocument_results.extend(subdoc_results) +# +# # Step 7: Process images +# image_results = [] +# if item_data.get('images'): +# for index, img_data in enumerate(item_data['images']): +# try: +# img_result = self.save_media_image(img_data, media, index) +# image_results.append(img_result) +# except Exception as img_error: +# print(f"Warning: Image save failed: {img_error}") +# image_results.append({ +# 'success': False, +# 'error': str(img_error) +# }) +# +# # Step 8: Success - clean up cache +# if file_key and cache.get(file_key): +# cache.delete(file_key) +# print(f"Cleaned up cache for {file_key}") +# +# return { +# 'success': True, +# 'filename': filename, +# 'media_id': media.id, +# 'message': 'Successfully saved', +# 'file_index': file_index, +# 'vector_db_saved': vector_result['successful'], +# 'vector_wait_time': vector_result.get('wait_time', 0), +# 'vector_task_id': vector_task_id, +# 'subdocument_results': subdocument_results, +# 'source_document_results': source_document_results, +# 'image_results': image_results +# } +# +# except Exception as unexpected_error: +# print(f"Unexpected error processing {filename}: {unexpected_error}") +# traceback.print_exc() +# return { +# 'success': False, +# 'filename': filename, +# 'message': f'Unexpected error: {str(unexpected_error)}', +# 'error_type': 'UNEXPECTED_ERROR', +# 'file_index': file_index, +# 'file_key': file_key, +# 'session_id': session_id, +# 'vector_db_saved': False +# } +# +# def _cache_subdocuments_recursive(self, subdocuments, session_id, parent_index, parent_path): +# """Cache subdocuments recursively for retry purposes""" +# for i, subdoc in enumerate(subdocuments): +# current_path = f"{parent_path}_{i}" if parent_path else str(i) +# +# # Cache this subdocument with all its data +# CacheManager.cache_subdocument(subdoc, session_id, parent_index, current_path) +# +# # Recursively cache nested subdocuments +# if subdoc.get('subdocument'): +# self._cache_subdocuments_recursive( +# subdoc['subdocument'], +# session_id, +# parent_index, +# current_path +# ) +# +# def process_subdocuments_recursive(self, subdocuments, parent_media, company_bot_id, user_profile, +# company_slug, session_id, parent_index, parent_path): +# """Recursively process subdocuments at any depth""" +# results = [] +# +# for i, subdoc_data in enumerate(subdocuments): +# current_path = f"{parent_path}_{i}" if parent_path else str(i) +# subdoc_cache_key = CacheManager.get_cache_key(session_id, 'subdoc', f"{parent_index}_{current_path}") +# +# try: +# subdoc_result = self.save_subdocument( +# subdoc_data, parent_media, company_bot_id, user_profile, company_slug +# ) +# subdoc_result['cache_key'] = subdoc_cache_key +# subdoc_result['path'] = current_path +# +# # If this subdocument has nested subdocuments, process them recursively +# if subdoc_data.get('subdocument') and subdoc_result['success']: +# subdoc_media_id = subdoc_result['subdoc_media_id'] +# subdoc_media = Media.objects.get(id=subdoc_media_id) +# +# nested_results = self.process_subdocuments_recursive( +# subdoc_data['subdocument'], +# subdoc_media, +# company_bot_id, +# user_profile, +# company_slug, +# session_id, +# parent_index, +# current_path +# ) +# subdoc_result['nested_subdocument_results'] = nested_results +# +# results.append(subdoc_result) +# +# except Exception as subdoc_error: +# print(f"Warning: Subdocument save failed: {subdoc_error}") +# results.append({ +# 'success': False, +# 'error': str(subdoc_error), +# 'cache_key': subdoc_cache_key, +# 'path': current_path, +# 'title': subdoc_data.get('title', f'Subdocument at {current_path}') +# }) +# +# return results +# +# def save_subdocument(self, subdoc_data, parent_media, company_bot_id, user_profile, company_slug): +# """Save a subdocument as a separate Media object linked to parent""" +# try: +# source_doc_url = subdoc_data.get('source_document') +# actual_parent = parent_media +# +# if source_doc_url: +# # Try to get or create the source document media +# source_media = self.get_or_create_source_document_media( +# source_doc_url, +# parent_media, +# company_bot_id, +# user_profile, +# company_slug +# ) +# +# if source_media: +# # Use the source document as the parent instead +# actual_parent = source_media +# print(f"Using source document {source_media.id} as parent for subdocument") +# +# file_url = subdoc_data.get('file_url') +# if not file_url: +# raise ValueError(f"No file URL provided for subdocument") +# +# # Validate file format based on URL extension before downloading +# from urllib.parse import urlparse, unquote +# parsed_url = urlparse(file_url) +# path = unquote(parsed_url.path) +# +# # Check if URL has an extension and validate it +# if '.' in path: +# url_extension = path.rsplit('.', 1)[-1].lower() +# if url_extension and not FileTypeChoices.is_valid_extension(url_extension): +# raise ValueError(f"Unsupported file format: .{url_extension}") +# +# print(f"Downloading file from URL: {file_url}") +# headers = { +# 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', +# 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' +# } +# +# try: +# response = requests.get(file_url, headers=headers, timeout=30, allow_redirects=True) +# response.raise_for_status() +# except requests.exceptions.RequestException as e: +# error_msg = f"Failed to download file from {file_url}: {str(e)}" +# print(f"Error: {error_msg}") +# raise ValueError(error_msg) +# +# # Additional validation based on content-type +# content_type = response.headers.get('content-type', '').lower() +# +# # Map content types to file extensions +# content_type_mapping = { +# 'application/pdf': 'pdf', +# 'application/msword': 'doc', +# 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', +# 'text/plain': 'txt', +# 'text/csv': 'csv', +# 'application/vnd.ms-excel': 'xls', +# 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', +# } +# +# # Check if content type is supported +# content_extension = None +# for mime_type, ext in content_type_mapping.items(): +# if mime_type in content_type: +# content_extension = ext +# break +# +# if content_extension and not FileTypeChoices.is_valid_extension(content_extension): +# raise ValueError(f"Unsupported content type: {content_type}") +# +# # Determine filename from URL or content-disposition +# filename = None +# content_disposition = response.headers.get('content-disposition') +# if content_disposition: +# import re +# matches = re.findall('filename="?([^"]+)"?', content_disposition) +# if matches: +# filename = matches[0] +# # Validate filename extension +# if '.' in filename: +# file_ext = filename.rsplit('.', 1)[-1].lower() +# if not FileTypeChoices.is_valid_extension(file_ext): +# raise ValueError(f"Unsupported file format in download: .{file_ext}") +# +# if not filename: +# # Extract from URL +# from urllib.parse import urlparse, unquote +# parsed_url = urlparse(file_url) +# path = parsed_url.path +# filename = os.path.basename(unquote(path)) +# +# # For Google Docs/Drive, create appropriate filename based on media type +# if 'docs.google.com' in file_url or 'drive.google.com' in file_url: +# base_title = subdoc_data.get('title', 'Document') +# media_type = subdoc_data.get('media_type', FileTypeChoices.TXT.value) +# +# # Get extension from media type using the enum's mapping +# extension_mapping = FileTypeChoices.get_extension_mapping() +# extension = extension_mapping.get(media_type, '.txt') +# +# filename = f"{slugify(base_title, allow_unicode=True)}{extension}" +# +# # Ensure filename has an extension +# if not os.path.splitext(filename)[1]: +# # Add extension based on media type using the enum's mapping +# media_type = subdoc_data.get('media_type', FileTypeChoices.TXT.value) +# extension_mapping = FileTypeChoices.get_extension_mapping() +# extension = extension_mapping.get(media_type, '.txt') +# filename += extension +# +# # Use filename (without extension) as the subdocument title +# filename_without_ext = os.path.splitext(filename)[0] if filename else "" +# +# if filename_without_ext and len(filename_without_ext.strip()) > 0: +# subdoc_title = filename_without_ext +# print(f"Using filename as title: {subdoc_title}") +# else: +# llm_title = subdoc_data.get('title', '').strip() +# if llm_title and len(llm_title) > 0: +# subdoc_title = llm_title +# print(f"Using LLM-extracted title: {subdoc_title}") +# else: +# # Final fallback - create a descriptive title +# subdoc_title = f"Document from {Path(urlparse(file_url).path).name or 'linked document'}" +# print(f"Using fallback title: {subdoc_title}") +# +# print(f"Saving subdocument with title: {subdoc_title} (from filename: {filename})") +# +# # Check for forced failure +# # for kv in subdoc_data.get('key_values', []): +# # if "fail" in kv.get('value', '').lower(): +# # print(f"Forced subdoc extraction failure for {subdoc_title}") +# # raise ValueError(f"Forced subdoc extraction failure for {subdoc_title}") +# +# # Get file content +# file_content = response.content +# if not file_content: +# raise ValueError(f"Downloaded file is empty for URL: {file_url}") +# +# # IMPORTANT FIX: Get organization from subdocument data FIRST +# subdoc_org = subdoc_data.get('organization', '') +# +# # If subdocument has no organization, try to get from key-values +# if not subdoc_org: +# for kv in subdoc_data.get('key_values', []): +# if kv.get('key') == 'ORGANIZATION' and kv.get('value'): +# subdoc_org = kv.get('value') +# break +# +# # If still no organization, get from parent media's key-values +# if not subdoc_org: +# parent_kvs = KeyValue.objects.filter(media=parent_media, key='ORGANIZATION') +# if parent_kvs.exists(): +# subdoc_org = parent_kvs.first().value +# +# # Only use company name as last resort +# if not subdoc_org and user_profile and user_profile.company: +# subdoc_org = user_profile.company.name +# +# subdoc_org = self.clean_text_to_title_case(subdoc_org) +# print(f"Subdocument organization resolved to: {subdoc_org}") +# organization_instance = None +# if subdoc_data.get('organization_slug'): +# try: +# organization_instance = Company.objects.get(slug=subdoc_data['organization_slug']) +# except Company.DoesNotExist: +# print(f"Warning: Company with slug {subdoc_data['organization_slug']} not found") +# +# # Create subdocument media +# subdoc_media = Media( +# name=subdoc_title, +# media_type=subdoc_data.get('media_type', FileTypeChoices.TXT.value), +# priority=parent_media.priority, +# description=subdoc_data.get('description', subdoc_data.get('summary', '')), +# company_bot_id=company_bot_id, +# parent=actual_parent, +# organization=organization_instance, +# display_mode=subdoc_data.get('display_mode', FileDisplayMode.VISIBLE), +# ) +# +# # Save the file content - use the original filename +# try: +# subdoc_media.file.save(filename, ContentFile(file_content), save=False) +# print(f"Successfully saved file: {filename}") +# except Exception as e: +# error_msg = f"Failed to save file content for subdocument: {str(e)}" +# print(f"Error: {error_msg}") +# raise ValueError(error_msg) +# +# # Save the media object +# subdoc_media.save() +# +# selected_company = None +# if subdoc_data.get('organization_slug'): +# try: +# selected_company = Company.objects.get(slug=subdoc_data['organization_slug']) +# except Company.DoesNotExist: +# pass +# +# if not selected_company and user_profile: +# selected_company = user_profile.company +# +# if not selected_company: +# company_bot = CompanyBot.objects.get(id=company_bot_id) +# selected_company = company_bot.company +# +# all_tags = [] +# +# manual_tags = subdoc_data.get('manual_tags', []) +# if manual_tags: +# manual_tag_objs = TagProcessor.process_tags_for_media( +# manual_tags, +# 'manual', +# user_profile, +# selected_company, +# is_manual=True +# ) +# all_tags.extend(manual_tag_objs) +# +# auto_tags = subdoc_data.get('auto_tags', []) +# if auto_tags: +# auto_tag_objs = TagProcessor.process_tags_for_media( +# auto_tags, +# 'extracted', +# user_profile, +# selected_company, +# is_manual=False +# ) +# all_tags.extend(auto_tag_objs) +# +# if all_tags: +# subdoc_media.tags.set(all_tags) +# +# # Key-value pairs - handle organization specially +# for kv in subdoc_data.get('key_values', []): +# if kv['key'] == 'DOCUMENT TYPE': +# doc_type_value = kv['value'] +# if isinstance(doc_type_value, dict): +# actual_value = doc_type_value.get('type', '') +# actual_value = actual_value.title() if actual_value else '' +# else: +# actual_value = doc_type_value.title() if doc_type_value else '' +# +# KeyValue.objects.create( +# media=subdoc_media, +# key='DOCUMENT TYPE', +# value=actual_value +# ) +# else: +# KeyValue.objects.create( +# media=subdoc_media, +# key=kv['key'], +# value=kv['value'] +# ) +# +# print(f"Saved {len(subdoc_data.get('key_values', []))} key-values for subdoc: {subdoc_title}") +# +# # Process subdocument images +# if subdoc_data.get('images'): +# for index, img_data in enumerate(subdoc_data['images']): +# self.save_media_image(img_data, subdoc_media, index) +# +# return { +# 'success': True, +# 'subdoc_media_id': subdoc_media.id, +# 'title': subdoc_media.name +# } +# +# except Exception as e: +# print(f"Error saving subdocument: {e}") +# traceback.print_exc() +# return { +# 'success': False, +# 'error': str(e), +# 'title': subdoc_data.get('title', 'Unknown subdocument') +# } +# +# def save_media_image(self, img_data, media, index): +# """Save image associated with media""" +# try: +# if img_data.get('base64'): +# try: +# # Extract image format from base64 string +# base64_str = img_data['base64'] +# if base64_str.startswith('data:'): +# mime_start = base64_str.find('image/') + 6 +# mime_end = base64_str.find(';', mime_start) +# image_format = base64_str[mime_start:mime_end] +# base64_data = base64_str.split(',')[1] +# else: +# image_format = img_data.get('format', 'png') +# base64_data = base64_str +# +# # Decode base64 to bytes +# image_bytes = base64.b64decode(base64_data) +# base_name, _ = os.path.splitext(media.name) +# safe_base = slugify(base_name, allow_unicode=True) +# file_name = f"img_{safe_base}_{index}.{image_format}" +# +# media_image = MediaImage( +# name=file_name, +# media=media, +# page=img_data.get('page'), +# index=img_data.get('index', index), +# width=img_data.get('width'), +# height=img_data.get('height'), +# base64_str=img_data.get('base64', '') +# ) +# +# # Create file +# media_image.file.save(file_name, ContentFile(image_bytes), save=False) +# +# # Set media type +# if image_format.lower() in ['jpg', 'jpeg']: +# media_image.media_type = MediaTypeChoices.JPEG +# elif image_format.lower() == 'png': +# media_image.media_type = MediaTypeChoices.PNG +# elif image_format.lower() == 'svg': +# media_image.media_type = MediaTypeChoices.SVG +# elif image_format.lower() == 'webp': +# media_image.media_type = MediaTypeChoices.WEBP +# +# media_image.save() +# +# return { +# 'success': True, +# 'image_id': media_image.id, +# 'page': media_image.page +# } +# +# except Exception as e: +# print(f"Error processing image base64: {e}") +# return { +# 'success': False, +# 'error': str(e) +# } +# +# except Exception as e: +# print(f"Error saving media image: {e}") +# return { +# 'success': False, +# 'error': str(e) +# } +# +# def post(self, request): +# try: +# self._source_doc_cache = {} +# data = json.loads(request.body) +# company_bot_id = data.get('company_bot_id') +# media_items = data.get('items', []) +# session_id = data.get('session_id') +# +# results = [] +# stats = { +# 'total': len(media_items), +# 'successful': 0, +# 'failed': 0, +# 'partial_success': 0, +# 'timeouts': 0, +# 'similarity_failures': 0 +# } +# +# # Get current user's profile +# try: +# user_profile = Profile.objects.get(email=request.user.email) +# except Profile.DoesNotExist: +# user_profile = None +# +# print(f"Starting batch save for {len(media_items)} files") +# +# # Process each file with fault tolerance +# for i, item_data in enumerate(media_items): +# filename = item_data.get('filename', f'File_{i}') +# print(f"Processing file {i + 1}/{len(media_items)}: {filename}") +# +# try: +# bypass_similarity = item_data.get('bypass_similarity', False) +# result = self.save_single_item_with_vector_db_wait_safe( +# item_data=item_data, +# company_bot_id=company_bot_id, +# user_profile=user_profile, +# session_id=session_id, +# bypass_similarity=bypass_similarity +# ) +# +# # Track statistics +# if result['success']: +# stats['successful'] += 1 +# else: +# stats['failed'] += 1 +# if result.get('partial_success'): +# stats['partial_success'] += 1 +# if result.get('error_type') in ['VECTOR_DB_TIMEOUT', 'WAIT_ERROR']: +# stats['timeouts'] += 1 +# if result.get('error_type') == 'SIMILARITY_CHECK_FAILED': +# stats['similarity_failures'] += 1 +# +# results.append(result) +# print( +# f"File {i + 1} result: {'✓' if result['success'] else '✗'} - {result.get('message', 'No message')}") +# +# except Exception as item_error: +# print(f"Critical error processing {filename}: {item_error}") +# stats['failed'] += 1 +# results.append({ +# 'success': False, +# 'filename': filename, +# 'message': f'Critical processing error: {str(item_error)}', +# 'error_type': 'CRITICAL_ERROR', +# 'file_index': item_data.get('file_index', i), +# 'file_key': item_data.get('file_key'), +# 'session_id': session_id, +# 'vector_db_saved': False +# }) +# +# # Preserve cache for failed files +# failed_cache_keys = [] +# for r in results: +# if not r['success'] and r.get('file_key'): +# failed_cache_keys.append(r['file_key']) +# # Also preserve cache for failed subdocuments +# if r.get('subdocument_results'): +# for subdoc_result in r['subdocument_results']: +# if not subdoc_result.get('success') and subdoc_result.get('cache_key'): +# failed_cache_keys.append(subdoc_result['cache_key']) +# +# if failed_cache_keys: +# CacheManager.extend_cache_timeout(failed_cache_keys) +# +# # Generate summary message +# summary_message = self.generate_batch_summary(stats) +# print(f"Batch complete: {summary_message}") +# +# return JsonResponse({ +# 'success': True, +# 'results': results, +# 'stats': stats, +# 'summary_message': summary_message, +# 'session_id': session_id +# }) +# +# except json.JSONDecodeError: +# return JsonResponse({ +# 'success': False, +# 'error': 'Invalid JSON data' +# }, status=400) +# except Exception as batch_error: +# print(f"Batch processing error: {batch_error}") +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': f'Batch processing failed: {str(batch_error)}' +# }, status=500) +# +# def generate_batch_summary(self, stats): +# """Generate human-readable batch summary""" +# total = stats['total'] +# successful = stats['successful'] +# failed = stats['failed'] +# +# if successful == total: +# return f"All {total} files processed successfully!" +# elif successful == 0: +# return f"All {total} files failed to process." +# else: +# message_parts = [f"{successful}/{total} files successful"] +# if failed > 0: +# message_parts.append(f"{failed} failed") +# if stats['timeouts'] > 0: +# message_parts.append(f"{stats['timeouts']} timed out") +# if stats['similarity_failures'] > 0: +# message_parts.append(f"{stats['similarity_failures']} similarity check failures") +# if stats['partial_success'] > 0: +# message_parts.append(f"{stats['partial_success']} partial successes") +# +# return ", ".join(message_parts) + "." +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class BatchMediaRetrySaveView(View): +# """API endpoint for retrying save of a single media item""" +# +# def post(self, request): +# try: +# data = json.loads(request.body) +# item_data = data.get('item_data') +# company_bot_id = data.get('company_bot_id') +# session_id = data.get('session_id') +# bypass_similarity = data.get('bypass_similarity', False) +# is_subdocument = data.get('is_subdocument', False) +# parent_media_id = data.get('parent_media_id') +# +# # Get current user's profile +# try: +# user_profile = Profile.objects.get(email=request.user.email) +# except Profile.DoesNotExist: +# user_profile = None +# +# if is_subdocument and parent_media_id: +# # Retry subdocument save +# try: +# parent_media = Media.objects.get(id=parent_media_id) +# company_bot = CompanyBot.objects.get(id=company_bot_id) +# company_slug = company_bot.company.slug +# +# save_view = BatchMediaSaveView() +# result = save_view.save_subdocument( +# subdoc_data=item_data, +# parent_media=parent_media, +# company_bot_id=company_bot_id, +# user_profile=user_profile, +# company_slug=company_slug +# ) +# +# return JsonResponse({ +# 'success': True, +# 'result': result +# }) +# except Exception as e: +# print(f"Subdocument retry error: {e}") +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': str(e) +# }, status=400) +# else: +# # Retry main document save +# save_view = BatchMediaSaveView() +# result = save_view.save_single_item_with_vector_db_wait_safe( +# item_data=item_data, +# company_bot_id=company_bot_id, +# user_profile=user_profile, +# session_id=session_id, +# bypass_similarity=bypass_similarity +# ) +# +# return JsonResponse({ +# 'success': True, +# 'result': result +# }) +# +# except Exception as e: +# print(f"Unexpected error in retry save: {e}") +# traceback.print_exc() +# return JsonResponse({ +# 'success': False, +# 'error': str(e) +# }, status=400) +# +# +# @method_decorator(staff_member_required, name='dispatch') +# class VectorDBTaskStatusView(View): +# """Check status of vector DB save task""" +# +# def post(self, request): +# try: +# from celery.result import AsyncResult +# data = json.loads(request.body) +# task_id = data.get('task_id') +# +# if not task_id: +# return JsonResponse({'success': False, 'error': 'No task_id provided'}) +# +# task = AsyncResult(task_id) +# +# return JsonResponse({ +# 'success': True, +# 'status': task.status, +# 'ready': task.ready(), +# 'successful': task.successful() if task.ready() else None, +# 'result': task.result if task.ready() else None +# }) +# +# except Exception as e: +# return JsonResponse({'success': False, 'error': str(e)}, status=400) diff --git a/chatbot/views/admin/post_processing_views.py b/chatbot/views/admin/post_processing_views.py new file mode 100644 index 0000000..16796d1 --- /dev/null +++ b/chatbot/views/admin/post_processing_views.py @@ -0,0 +1,328 @@ +from django.views.generic import TemplateView +from django.contrib.admin.views.decorators import staff_member_required +from django.utils.decorators import method_decorator +from django.http import JsonResponse +import json +import os +import traceback +from chatbot.utils.shiksha_chaupal.iterative_challenge_processor import run_iterative_challenge_filtering +from chatbot.utils.admin_config.config import ( + get_all_processing_types, + get_processing_type_by_value, + ProcessingType +) +from chatbot.celery_tasks.post_processing_tasks import run_unique_challenges_task, run_unique_solutions_task +from celery.result import AsyncResult + + +@method_decorator(staff_member_required, name='dispatch') +class PostProcessingView(TemplateView): + """Post Processing view for Story model admin""" + template_name = 'admin/post_processing/post_processing.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['model_name'] = 'Story' + # Dynamically get all processing types from config + context['processing_types'] = get_all_processing_types() + return context + + def get(self, request, *args, **kwargs): + """Handle GET requests - either render template or check task status""" + task_id = request.GET.get('task_id') + + if task_id: + # Check task status + return self._check_task_status(task_id) + + # Normal template rendering + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """Handle POST request for running the processing script""" + try: + processing_type = request.POST.get('processing_type', '') + + # Get processing type enum and validate + processing_type_enum = get_processing_type_by_value(processing_type) + if not processing_type_enum: + return JsonResponse({ + 'success': False, + 'error': f'Unknown processing type: {processing_type}' + }) + + # Dynamically extract and validate fields based on config + config = self._extract_form_data(request, processing_type_enum) + + # Check for validation errors + if 'error' in config: + return JsonResponse({ + 'success': False, + 'error': config['error'] + }) + + # Common fields: file upload and date range + input_file = request.FILES.get('input_file', None) + date_from = request.POST.get('date_from', '').strip() + date_till = request.POST.get('date_till', '').strip() + + # Validate at least one input source + has_file = input_file is not None + has_date_range = bool(date_from and date_till) + + if not has_file and not has_date_range: + return JsonResponse({ + 'success': False, + 'error': 'Please provide either an input file or a date range to begin processing.' + }) + + # Add common fields to config + config.update({ + 'processing_type': processing_type, + 'date_from': date_from, + 'date_till': date_till, + 'input_file': input_file.name if input_file else None, + 'has_file': has_file, + 'has_date_range': has_date_range + }) + + # Run the appropriate processing using dynamic routing + # Get the handler method name from config + handler_method_name = processing_type_enum.handler_method + handler_method = getattr(self, handler_method_name, None) + + if handler_method: + result = handler_method(config, input_file) + return JsonResponse(result) + else: + return JsonResponse({ + 'success': False, + 'error': f'Handler method {handler_method_name} not found for {processing_type}' + }) + + except Exception as e: + import traceback + traceback.print_exc() + return JsonResponse({ + 'success': False, + 'error': str(e) + }) + + def _extract_form_data(self, request, processing_type_enum): + """ + Dynamically extract and validate form fields based on processing type config. + """ + from chatbot.utils.admin_config.config import get_processing_type_config + + config_data = get_processing_type_config(processing_type_enum) + field_definitions = config_data.get('fields', []) + + extracted_data = {} + + for field_def in field_definitions: + field_name = field_def['name'] + field_type = field_def['type'] + default_value = field_def.get('default') + + # Get value from request + raw_value = request.POST.get(field_name) + + # Use default if not provided + if raw_value is None or raw_value == '': + extracted_data[field_name] = default_value + continue + + # Type conversion and validation + try: + if field_type == 'number': + # Check if it has step (float) or is integer + if 'step' in field_def and field_def['step'] != 1: + converted_value = float(raw_value) + else: + converted_value = int(raw_value) + + # Validate min/max + if 'min' in field_def and converted_value < field_def['min']: + return {'error': f"{field_def['label']} must be at least {field_def['min']}"} + if 'max' in field_def and converted_value > field_def['max']: + return {'error': f"{field_def['label']} must be at most {field_def['max']}"} + + extracted_data[field_name] = converted_value + + elif field_type == 'select': + # Validate choice is in allowed choices + valid_choices = [choice['value'] for choice in field_def.get('choices', [])] + if valid_choices and raw_value not in valid_choices: + return {'error': f"Invalid value for {field_def['label']}"} + extracted_data[field_name] = raw_value + + elif field_type == 'text': + extracted_data[field_name] = raw_value.strip() + + else: + # Default: store as-is + extracted_data[field_name] = raw_value + + except (ValueError, TypeError) as e: + return {'error': f"Invalid value for {field_def['label']}: {str(e)}"} + + return extracted_data + + def _run_unique_challenges_processing(self, config, input_file): + """Trigger async task for unique challenges processing.""" + + # Prepare input file content if uploaded + input_file_content = None + + if config.get('has_file') and input_file: + try: + # Read the uploaded file content + content = input_file.read().decode('utf-8') + input_file_content = content + except Exception as e: + return { + 'success': False, + 'error': f'Unable to read the uploaded file. Please ensure it is a valid JSON format.' + } + + # Trigger the async Celery task + try: + import traceback + + task = run_unique_challenges_task.delay(config, input_file_content) + + print(f"✅ Task triggered successfully. Task ID: {task.id}") + + return { + 'success': True, + 'task_id': task.id, + 'message': 'Task has been started.' + } + except Exception as e: + print(f"❌ Failed to trigger task: {str(e)}") + traceback.print_exc() + return { + 'success': False, + 'error': f'Failed to start task: {str(e)}' + } + + def _run_unique_solutions_processing(self, config, input_file): + """Trigger async task for unique solutions processing.""" + + # Prepare input file content if uploaded + input_file_content = None + + if config.get('has_file') and input_file: + try: + input_file_content = input_file.read().decode('utf-8') + print(f"✅ Read input file: {input_file.name}") + except Exception as e: + return {'success': False, 'error': f'Failed to read file: {str(e)}'} + + # Trigger the async Celery task + try: + print(f"✅ Triggering unique solutions task with config: {config}") + + task = run_unique_solutions_task.delay(config, input_file_content) + + print(f"✅ Task triggered successfully. Task ID: {task.id}") + + return { + 'success': True, + 'task_id': task.id, + 'message': 'Task has been started.' + } + except Exception as e: + print(f"❌ Failed to trigger task: {str(e)}") + traceback.print_exc() + return { + 'success': False, + 'error': f'Failed to start task: {str(e)}' + } + + def _check_task_status(self, task_id): + """Check the status of a Celery task.""" + try: + task_result = AsyncResult(task_id) + + if task_result.state == 'PENDING': + return JsonResponse({ + 'state': 'PENDING', + 'status': 'Task is waiting to be processed...' + }) + elif task_result.state == 'SUCCESS': + result = task_result.result + if result.get('success'): + return JsonResponse({ + 'state': 'SUCCESS', + 'success': True, + 'status': 'completed', + 'message': result.get('message'), + 'output_file': result.get('output_file'), + 'iterations': result.get('iterations'), + 'final_count': result.get('final_count'), + 'category_counts': result.get('category_counts', {}), + 'stats': result.get('stats', []) + }) + else: + return JsonResponse({ + 'state': 'SUCCESS', + 'success': False, + 'error': result.get('error', 'Processing failed') + }) + elif task_result.state == 'FAILURE': + # Get detailed error information + error_info = task_result.info + error_message = str(error_info) + + # If it's an exception, get more details + if hasattr(error_info, '__class__'): + error_message = f"{error_info.__class__.__name__}: {str(error_info)}" + + print(f"❌ Task FAILURE detected. Error: {error_message}") + print(f"📊 Task result info: {task_result.info}") + + return JsonResponse({ + 'state': 'FAILURE', + 'success': False, + 'error': error_message + }) + else: + return JsonResponse({ + 'state': task_result.state, + 'status': f'Task state: {task_result.state}' + }) + + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'Error checking task status: {str(e)}' + }) + + def _print_processing_output(self, config): + processing_type = config.get('processing_type', '') + + print("\n" + "=" * 60) + print("🚀 POST PROCESSING") + print("=" * 60) + + if processing_type in ['unique_challenges', 'unique_solutions']: + type_label = 'Challenges' if processing_type == 'unique_challenges' else 'Solutions' + print(f"\n📋 Configuration:") + print(f" Type: Unique {type_label}") + print(f" MAX_WORKERS: {config.get('max_workers')}") + print(f" BATCH_SIZE: {config.get('batch_size')}") + print(f" MAX_ITERATIONS: {config.get('max_iterations')}") + print(f" FILTER_THRESHOLD: {config.get('filter_threshold')}%") + + if config.get('has_file'): + print(f" Input File: {config.get('input_file')}") + + if config.get('has_date_range'): + print(f" Date Range: {config.get('date_from')} to {config.get('date_till')}") + + print(f"\n✅ Processing complete!") + else: + print(f"❓ Unknown processing type: {processing_type}") + + print("=" * 60 + "\n") diff --git a/chatbot/views/api_views.py b/chatbot/views/api_views.py new file mode 100644 index 0000000..cc6657b --- /dev/null +++ b/chatbot/views/api_views.py @@ -0,0 +1,187 @@ +import logging +import traceback +from django.contrib.auth.hashers import check_password +from rest_framework.response import Response +from rest_framework.decorators import api_view, authentication_classes +from chatbot.models import ProfileType +from chatbot.models.geo_models import ProfileAddress +from chatbot.models.auth_models import BlacklistedToken +from chatbot.models.company_models import Company, CompanyBot +from chatbot.models.profile_models import Profile +from chatbot.serializer.profile_serializer import ProfileSerializer +from django.http import JsonResponse +from django.contrib.sessions.backends.db import SessionStore +from rest_framework_simplejwt.tokens import RefreshToken +from chatbot.translate.ai4Bharat.transliterate import call_ai4bharat_transliterate_api +from chatbot.models.company_models import Flow + +logger = logging.getLogger('django') + + +def generate_session_id(request): + try: + session = SessionStore() + session.create() + return JsonResponse({'sessionid': session.session_key}) + except Exception as e: + print('Exception is here') + print(e) + traceback.print_exc() + + +@api_view(['POST']) +def post_profile(request): + try: + data = request.data + if not ('email' in data and 'company' in data): + return Response({ + 'status': 'error', + 'message': 'email and subdomain/company are mandatory' + }, status=400) + company_slug = data.get('company') + company = Company.objects.get(slug=company_slug) + + email = data['email'] + first_name = data.get('first_name', '') + target_language = data.get('preferred_route', None) + if first_name and first_name != '' and target_language: + source_language='en' + first_names = call_ai4bharat_transliterate_api( + source_language=source_language, target_language=target_language, message_body=first_name + ) + if first_names and isinstance(first_names, dict): + first_names = first_names.get('content', []) + if first_names and isinstance(first_names, list) and len(first_names) > 0: + data['first_name'] = first_names[0] + + # handling the latest flow + flow_route = data.get('latest_flow', None) + + if flow_route: + flow = Flow.objects.values('id').get(flow_route=flow_route) + data['latest_flow'] = flow.get('id') + + profile = Profile.objects.filter(email=email, company=company).first() + if profile: + serializer = ProfileSerializer(profile, data=data) + else: + phone = data.get('phone', None) + if phone: + profile = Profile.objects.filter(phone=phone, company=company) + if len(profile) > 0: + serializer = ProfileSerializer(profile[0]) + return Response(serializer.data) + serializer = ProfileSerializer(data=data) + if serializer.is_valid(): + serializer.save(company=company) + return Response(serializer.data) + else: + return Response({ + 'status': 'error', + 'message': 'Invalid data', + 'errors': serializer.errors + }, status=400) + + except Company.DoesNotExist: + return Response({ + 'status': 'error', + 'message': 'Company does not exist' + }, status=404) + + except Flow.DoesNotExist: + return Response({ + 'status': 'error', + 'message': 'Flow does not exist' + }, status=404) + + except Exception as e: + traceback.print_exc() + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def login(request): + try: + if 'email' in request.data and 'password' in request.data: + email = request.data['email'] + password = request.data['password'] + print("Login User Email: ", email) + print("Login User Password: ", password) + + p = Profile.objects.filter(email=email) + if len(p) > 0: + p = p[0] + if check_password(password, p.password): + profile_address = ProfileAddress.objects.filter(profile=p) + if len(profile_address) > 0: + state = profile_address[0].state + else: + state = '' + token = RefreshToken.for_user(p) + access_token = str(token.access_token) + request.session['is_authenticated'] = True + request.session['profileid'] = p.id + return Response({ + 'status': 'ok', + 'id': p.id, + 'first_name': p.first_name, + 'email': p.email, + 'access_token': access_token, + 'company': p.company.slug, + 'state': state + }, status=200) + else: + logger.error('Password incorrect') + return Response({ + 'status': 'error', + 'message': 'Password is incorrect' + }, status=401) + else: + logger.error('Profile does not exist') + return Response({ + 'status': 'error', + 'message': 'Profile does not exist' + }, status=400) + else: + logger.error('Email and Password are mandatory') + return Response({ + 'status': 'error', + 'message': 'Email and Password are mandatory' + }, status=400) + except Exception as e: + traceback.print_exc() + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def logout(request): + try: + # Blacklist the token + token = request.headers.get('Authorization', '').split(' ')[1] + if token: + BlacklistedToken.objects.create(token=token) + + # Clear the session data to log the user out + request.session.clear() + + response = Response({ + 'status': 'ok', + 'message': 'Logout successful' + }, status=200) + + response.delete_cookie('sessionid') + return response + except Exception as e: + traceback.print_exc() + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + diff --git a/chatbot/views/aws_views.py b/chatbot/views/aws_views.py new file mode 100644 index 0000000..3a0c18c --- /dev/null +++ b/chatbot/views/aws_views.py @@ -0,0 +1,134 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from chatbot.services.storage import StorageFactory +from chatbot.services.storage.base_storage_handler import UploadConfig +import time + + +@api_view(["POST"]) +def get_presigned_url(request): + """ + Generate a presigned URL for file upload using the configured cloud storage provider. + + Request body: + - fileName: Name of the file to upload + - fileType: MIME type of the file + - storyId: Optional story identifier + - folder_structure: Folder path prefix for organizing files + + Returns: + - uploadUrl: Presigned URL for uploading the file + - s3ObjectKey: Object key/path in storage + - s3Url: Public URL for accessing the uploaded file + """ + file_name = request.data.get("fileName") + file_type = request.data.get("fileType") + story_id = request.data.get("storyId") + folder_structure = request.data.get("folder_structure") + + print(f"file_name: {file_name}, file_type: {file_type}, story_id: {story_id}") + + if not file_name or not file_type: + return Response({"error": "Missing fileName or fileType"}, status=400) + + # Add timestamp to filename for uniqueness + timestamp_ms = int(time.time() * 1000) + file_name_with_timestamp = f"{timestamp_ms}-{file_name}" + + # Create upload configuration + upload_config = UploadConfig( + file_name=file_name_with_timestamp, + file_type=file_type, + folder_structure=folder_structure or '', + entity_id=story_id if story_id else None, + expires_in=3600 + ) + + try: + # Get storage handler from factory + storage_handler = StorageFactory.get_storage_handler() + + # Generate presigned URL using the storage handler + result = storage_handler.generate_presigned_url(upload_config) + + if not result.success: + return Response({"error": result.error or "Failed to generate presigned URL"}, status=500) + + return Response({ + "uploadUrl": result.upload_url, + "s3ObjectKey": result.object_key, + "s3Url": result.object_url, + }) + + except ValueError as e: + # Handle configuration errors + return Response({"error": str(e)}, status=500) + except Exception as e: + # Handle unexpected errors + return Response({"error": f"Internal server error: {str(e)}"}, status=500) + + +@api_view(["PUT"]) +def upload_media_local(request, object_key): + """ + Direct file upload endpoint for LOCAL storage provider. + This endpoint mimics AWS S3's presigned URL behavior where client PUTs file directly. + + URL pattern: /api/storage/upload-local/ + + Args: + request: HTTP PUT request with file in body + object_key: Pre-generated object key from presigned URL (includes folder/timestamp/filename) + + Returns: + - s3ObjectKey: Object key/path in storage + - s3Url: Public URL for accessing the uploaded file + - success: Boolean indicating upload success + """ + if not request.body: + return Response({"error": "No file data received"}, status=400) + + print(f"Uploading file to local storage: {object_key}") + + try: + # Get storage handler + storage_handler = StorageFactory.get_storage_handler() + + # Create a file-like object from request body + from io import BytesIO + file_obj = BytesIO(request.body) + + # Get file path from object_key + import os + from django.conf import settings + + storage_location = getattr(settings, 'MEDIA_ROOT', os.path.join(settings.BASE_DIR, 'media')) + file_path = os.path.join(storage_location, object_key) + + # Ensure directory exists + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # Write file to disk + with open(file_path, 'wb') as destination: + destination.write(file_obj.read()) + + # Get public URL + public_url = storage_handler.get_public_url(object_key) + + print(f"Successfully uploaded file to local storage: {object_key}") + + return Response({ + "s3ObjectKey": object_key, + "s3Url": public_url, + "success": True + }) + + except ValueError as e: + # Handle configuration errors + return Response({"error": str(e)}, status=500) + except Exception as e: + # Handle unexpected errors + import traceback + traceback.print_exc() + return Response({"error": f"Internal server error: {str(e)}"}, status=500) + diff --git a/chatbot/views/bhashini_views.py b/chatbot/views/bhashini_views.py new file mode 100644 index 0000000..95df2c9 --- /dev/null +++ b/chatbot/views/bhashini_views.py @@ -0,0 +1,184 @@ +import os +from rest_framework.decorators import api_view +from rest_framework.response import Response +from chatbot.models import CompanyBot, Voice, VoiceType +from chatbot.translate.ai4Bharat.text_lang_detect import call_ai4bharat_text_lang_detect_api +from chatbot.utils.audio_converter_utils import convert_s3_audio_to_wav_base64 +from chatbot.utils.audio_provider_utils import text_speech_provider, speech_text_provider, text_translate_provider +from chatbot.utils.transliterate_utils import transliterate_text +import logging + +logger = logging.getLogger('django') + + +ai4bharat_api_key = os.getenv("BHASHANI_API_KEY") + + +@api_view(['POST']) +def text_speech_view(request): + try: + body = request.data + text = body.get('text', '') + source_language = body.get('source_language', 'en') + route = body.get('route') + + if not route: + return Response({ + 'status': 'error', + 'message': 'route is a required field' + }, status=500) + + company_bot = CompanyBot.objects.filter(route=route).first() + response = text_speech_provider( + company_bot=company_bot, text=text, source_language=source_language + ) + + if response.get('status') == 200: + return Response({ + 'status': 'ok', + 'audio': response.get('content') + }, status=200) + else: + return Response({ + 'status': 'error', + 'message': response.get('content') + }, status=response.get('status')) + + except Exception as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def speech_text(request): + try: + body = request.data + s3_url = body.get('s3Url') + audio_format = body.get('audio_format', 'wav') + source_language = body.get('source_language', 'en') + route = body.get('route') + + company_bot = CompanyBot.objects.filter(route=route).first() + if not company_bot: + company_bot = CompanyBot.objects.filter(route='/common_bot').first() + + encoded_audio = convert_s3_audio_to_wav_base64(s3_url=s3_url) + + if not route: + return Response({ + 'status': 'error', + 'message': 'route is a required field' + }, status=500) + + response = speech_text_provider( + company_bot=company_bot, base64=encoded_audio, audio_format=audio_format, + source_language=source_language + ) + + if response.get('status') == 200: + return Response({ + 'status': 'ok', + 'transcript': response.get('content') + }, status=200) + else: + return Response({ + 'status': 'error', + 'message': response.get('content') + }, status=response.get('status')) + + except Exception as e: + logger.error("Error in speech_text: %s", e, exc_info=True) + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def text_translation_view(request): + try: + body = request.data + source_language = body.get('source_language', 'en') + target_language = body.get('target_language', 'en') + message_body = body.get('message_body') + + route = body.get('route') + + if not route: + return Response({ + 'status': 'error', + 'message': 'route is a required field' + }, status=500) + + company_bot = CompanyBot.objects.filter(route=route).first() + + response = text_translate_provider( + company_bot=company_bot, message_body=message_body, target_language=target_language, + source_language=source_language + ) + + if response.get('status') == 200: + return Response({ + 'status': 'ok', + 'transcript': response.get('content') + }, status=200) + else: + return Response({ + 'status': 'error', + 'message': response.get('content') + }, status=response.get('status')) + + except Exception as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def text_transliterate_view(request): + try: + body = request.data + source_language = body.get('source_language', 'en') + target_language = body.get('target_language', 'en') + message_body = body.get('message_body') + detect_language = body.get('detect_language', False) + route = body.get('route') + + if not route: + return Response({ + 'status': 'error', + 'message': 'route is a required field' + }, status=500) + + company_bot = CompanyBot.objects.filter(route=route).first() + if detect_language: + detected_body = call_ai4bharat_text_lang_detect_api(message_body=message_body) + if detected_body and detected_body.get('content'): + source_language = detected_body.get('content') + print("detected_body: ", detected_body) + print("setting source_language: ", source_language) + + response = transliterate_text( + company_bot=company_bot, message_body=message_body, target_language=target_language, + source_language=source_language + ) + + if response: + return Response({ + 'status': 'ok', + 'transcript': response + }, status=200) + else: + return Response({ + 'status': 'error', + 'message': response + }, status=500) + + except Exception as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) diff --git a/chatbot/views/chat_view.py b/chatbot/views/chat_view.py new file mode 100644 index 0000000..b083627 --- /dev/null +++ b/chatbot/views/chat_view.py @@ -0,0 +1,183 @@ +import os +from jwt import ExpiredSignatureError, InvalidTokenError +from rest_framework.decorators import api_view +from rest_framework.response import Response +from chatbot.models import CompanyChat, ChatSession, ChatStatus, Profile, Company, TextConversionType, Voice, VoiceType +import jwt +from django.http import JsonResponse +from chatbot.celery_tasks.ptm_report_tasks import create_ptm_report +from chatbot.utils.audio_provider_utils import text_translate_provider +from chatbot.utils.ptm_utils.chat_utils import save_question_answer_utils +from chatbot.utils.transliterate_utils import transliterate_text + +JWT_PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + + +@api_view(['POST']) +def save_chats_view(request): + body = request.data + message = body.get('message') + session = body.get('session') + status = body.get('status', 'COMPLETED') + role = body.get('role') + chunks = body.get('chunks') + user_profile = None + if not message or not session: + return Response({"error": "message and session are required."}, status=400) + + print("message: ", message) + + + try: + ai_user = Profile.objects.get(id=1) + except Profile.DoesNotExist: + return Response({"error": "AI profile not found."}, status=400) + + try: + chat_session = ChatSession.objects.get(session=session) + if chat_session: + user_profile = chat_session.profile + except ChatSession.DoesNotExist: + return Response({"error": "chat_session not found."}, status=400) + + + if role == 'bot': + sender = ai_user + receiver = user_profile + elif role == 'user': + sender = user_profile + receiver = ai_user + else: + return Response({"error": "Invalid role. Must be 'bot' or 'user'."}, status=400) + + CompanyChat.objects.create( + message=message, + session=session, + status=status, + sender=sender, + receiver=receiver, + chunks=chunks + ) + + + return Response({ + 'status': 'ok', + 'message': 'Message saved successfully!' + }, status=200) + + +@api_view(['POST']) +def create_chatsession(request): + body = request.data + session = body.get('session') + email = body.get('email') + preferred_language = body.get('preferred_language', {}).get('value') + + access_token = request.headers.get("X-auth-token") + if not access_token: + return JsonResponse({"message": "Access token missing"}, status=401) + + try: + decoded = jwt.decode( + access_token, + JWT_PUBLIC_KEY, + algorithms=["HS256"] + ) + user_id = decoded.get("data", {}).get("id") + first_name = decoded.get("data", {}).get("name") + user_roles = decoded.get("roles", []) + + except ExpiredSignatureError: + return JsonResponse({"message": "Access token expired"}, status=401) + + except InvalidTokenError: + return JsonResponse({"message": "Invalid access token"}, status=401) + + + if not session: + return Response({"error": "session is required."}, status=400) + + if not email: + return Response({"error": "Email is required."}, status=400) + + try: + company = Company.objects.get(slug='shikshalokamstaging') + except Exception as e: + return Response({"error": f"{e}"}, status=400) + + profile, created = Profile.objects.get_or_create( + userid = user_id, + defaults={ + 'first_name': first_name, + 'email': email, + 'password': 'grit@123', + 'preferred_route': preferred_language, + 'company': company, + "designation": user_roles + } + ) + + c, created = ChatSession.objects.get_or_create( + session=session, + defaults={ + 'session_status': ChatStatus.IN_PROGRESS, + 'profile': profile, + } + ) + + return Response({ + 'status': 'ok', + 'message': 'Chatsession created!' if created else 'Chatsession already exists!', + 'chatsession': { + 'session': c.session, + 'session_status': c.session_status, + 'profile_id': profile.id + } + }, status=200) + + +@api_view(['POST']) +def save_ptm_chats(request): + body = request.data + session = body.get('session') + status = body.get('status', 'COMPLETED') + flow = body.get('flow') + profile_id = body.get('profile_id') + question_id = body.get('id') + answer_id = body.get('answer_id') + sequence = body.get('sequence') + question = body.get('question') + translated_message = body.get('translated_question') + answer = body.get('answer') + language = body.get('language') + sent_at = body.get('sent_at') + audio_file = body.get('audio_url') + service = body.get('service') + # should_transliterate = body.get('should_transliterate', False) + + if not question or not session or not answer: + return Response({"error": "question, answer and session are required."}, status=400) + + res = save_question_answer_utils( + profile_id=profile_id, flow=flow, session=session, sequence=sequence, status=status, + language=language, question_id=question_id, sent_at=sent_at, question=question, + translated_message=translated_message, answer=answer, + audio_file=audio_file, answer_id=answer_id, service=service + # should_transliterate=should_transliterate, + ) + + if res.get("status") != 200: + return Response(res, status=res.get("status")) + + # if status == "COMPLETED": + # create_ptm_report.delay( + # profile_id=profile_id, + # session=session, + # flow=flow, + # language=language + # ) + + return Response({ + "status": "ok", + "message": res.get("message", "Message saved successfully!") + }, status=200) diff --git a/chatbot/views/drf_views.py b/chatbot/views/drf_views.py new file mode 100644 index 0000000..f920a24 --- /dev/null +++ b/chatbot/views/drf_views.py @@ -0,0 +1,193 @@ +import django_filters +from rest_framework import generics +from rest_framework.response import Response +from rest_framework import status +from chatbot.filter.drf_filter import ChatSessionProfileFilter +from chatbot.models import ChatSession, BotVernacular, SessionFlowName, ChatType +from chatbot.models.company_models import CompanyChat, CompanyBot, Flow +from chatbot.models.profile_models import Profile +from chatbot.serializer.base_serializer import ChatSessionSerializer +from chatbot.serializer.company_serializer import ( + CompanyBotSerializer, BotVernacularSerializer, ImageConfigurationSerializer, + FlowLanguagesSerializer, FlowConnectionInfoSerializer +) +from chatbot.serializer.profile_serializer import ProfileSerializer, CompanyChatSerializer + + +class CompanyChatListCreateView(generics.ListCreateAPIView): + queryset = CompanyChat.objects.all().order_by('created_at') + serializer_class = CompanyChatSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['message', 'sender', 'receiver', 'session', 'status'] + + +class CompanyChatRetrieveUpdateDestroyView(generics.RetrieveUpdateAPIView): + queryset = CompanyChat.objects.all() + serializer_class = CompanyChatSerializer + + +class CompanyBotListCreateView(generics.ListCreateAPIView): + queryset = CompanyBot.objects.all() + serializer_class = CompanyBotSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['name', 'company__name', 'llm_model', 'company__slug', 'route'] + + +class CompanyBotRetrieveUpdateDestroyView(generics.RetrieveUpdateAPIView): + queryset = CompanyBot.objects.all() + serializer_class = CompanyBotSerializer + + +class BotVernacularListCreateView(generics.ListCreateAPIView): + queryset = BotVernacular.objects.all() + serializer_class = BotVernacularSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['company_bot', 'language', 'company_bot__route'] + + +class BotVernacularRetrieveUpdateDestroyView(generics.RetrieveUpdateAPIView): + queryset = BotVernacular.objects.all() + serializer_class = BotVernacularSerializer + + +class ProfileListCreateView(generics.ListCreateAPIView): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['first_name', 'email', 'company__name', 'phone', 'company__slug'] + + +class ProfileRetrieveUpdateDestroyView(generics.RetrieveUpdateAPIView): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + + +class ChatSessionListCreateView(generics.ListCreateAPIView): + queryset = ChatSession.objects.all() + serializer_class = ChatSessionSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend, ChatSessionProfileFilter] + filterset_fields = ['session', 'project_id', 'user_id', 'profile', 'session_type'] + + +class ChatSessionRetrieveUpdateDestroyView(generics.RetrieveUpdateAPIView): + queryset = ChatSession.objects.all() + serializer_class = ChatSessionSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['session'] + + +class ChatSessionRetrieveUpdateDestroyViewSession(generics.RetrieveUpdateAPIView): + queryset = ChatSession.objects.all() + serializer_class = ChatSessionSerializer + lookup_field = 'session' + + +class FlowImageConfigView(generics.GenericAPIView): + """ + API endpoint to get image configuration for a specific flow route. + Query param: flow_route (required) + Returns: ImageConfiguration object or 404 + """ + serializer_class = ImageConfigurationSerializer + + def get(self, request, *args, **kwargs): + flow_route = request.query_params.get('flow_route') + + if not flow_route: + return Response( + {'error': 'flow_route query parameter is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + flow = Flow.objects.select_related('image_config_id').get( + flow_route=flow_route, + active=True + ) + + if not flow.image_config_id: + return Response( + {'error': 'No image configuration found for this flow'}, + status=status.HTTP_404_NOT_FOUND + ) + + serializer = self.get_serializer(flow.image_config_id) + return Response(serializer.data) + + except Flow.DoesNotExist: + return Response( + {'error': 'Flow not found or inactive'}, + status=status.HTTP_404_NOT_FOUND + ) + + +class FlowLanguagesView(generics.GenericAPIView): + """ + API endpoint to get supported languages for a specific flow route. + Query param: flow_route (required) + Returns: List of language codes + """ + serializer_class = FlowLanguagesSerializer + + def get(self, request, *args, **kwargs): + flow_route = request.query_params.get('flow_route') + + if not flow_route: + return Response( + {'error': 'flow_route query parameter is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + flow = Flow.objects.get( + flow_route=flow_route, + active=True + ) + + serializer = self.get_serializer(flow) + return Response(serializer.data) + + except Flow.DoesNotExist: + return Response( + {'error': 'Flow not found or inactive'}, + status=status.HTTP_404_NOT_FOUND + ) + + +class FlowConnectionInfoView(generics.GenericAPIView): + """ + API endpoint to get websocket URL and bot route for a flow. + Query param: flow_route (required) + Returns: websocket_url, bot route, isParentFlow flag, children flows, and image configuration + """ + serializer_class = FlowConnectionInfoSerializer + + def get(self, request, *args, **kwargs): + flow_route = request.query_params.get('flow_route') + + if not flow_route: + return Response( + {'error': 'flow_route query parameter is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + flow = Flow.objects.select_related('bot', 'image_config').prefetch_related('child_flows').get( + flow_route=flow_route + ) + + # Check if flow is active + if not flow.active: + return Response( + {'error': 'Flow is inactive'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(flow) + return Response(serializer.data) + + except Flow.DoesNotExist: + return Response( + {'error': 'Flow not found'}, + status=status.HTTP_404_NOT_FOUND + ) diff --git a/chatbot/views/gotenberg_view.py b/chatbot/views/gotenberg_view.py new file mode 100644 index 0000000..ede437b --- /dev/null +++ b/chatbot/views/gotenberg_view.py @@ -0,0 +1,57 @@ +from rest_framework.decorators import api_view +from django.http import HttpResponse +from chatbot.models import Story, ChatSession +from chatbot.utils.gotenberg_utils import generate_pdf_with_gotenberg +from chatbot.utils.shikshalokam_story_utils import get_story_html, get_html_from_template +from django.core.files.base import ContentFile + + +@api_view(['GET']) +def generate_pdf_view(request): + body = request.query_params + session = body.get("session") + flow = body.get('flow') + print("session: ", session) + story = Story.objects.get(session=session) + profile = story.author + + html_content = get_story_html(story=story, profile=profile, flow=flow) + print("--------------------") + print(html_content) + print("--------------------") + pdf_generated = generate_pdf_with_gotenberg(html_content) + pdf_file_name = f"Sample.pdf" + pdf_content = ContentFile(pdf_generated, name=pdf_file_name) + print("pdf_content: ", pdf_content) + print("pdf_content type: ", type(pdf_content)) + # if pdf_generated: + # http_response = HttpResponse(pdf_generated, content_type="application/pdf") + # http_response["Content-Disposition"] = 'inline; filename="output.pdf"' + # return http_response + # else: + # return HttpResponse("Error generating pdf!", status=500) + + return HttpResponse(html_content, content_type="text/html", status=200) + + +@api_view(['GET']) +def generate_pdf_view_v2(request): + body = request.query_params + session = body.get("session") + flow = body.get('flow') + print("session: ", session) + story = Story.objects.get(session=session) + chatsession = ChatSession.objects.get(session=session) + profile = story.author + + html_content = get_html_from_template(story=story, profile=profile, flow=flow, language=chatsession.language) + print("--------------------") + print(html_content) + print("--------------------") + pdf_generated = generate_pdf_with_gotenberg(html_content) + pdf_file_name = f"Sample.pdf" + pdf_content = ContentFile(pdf_generated, name=pdf_file_name) + print("pdf_content: ", pdf_content) + print("pdf_content type: ", type(pdf_content)) + + return HttpResponse(html_content, content_type="text/html", status=200) \ No newline at end of file diff --git a/chatbot/views/kafka_views.py b/chatbot/views/kafka_views.py new file mode 100644 index 0000000..e576e83 --- /dev/null +++ b/chatbot/views/kafka_views.py @@ -0,0 +1,29 @@ +from rest_framework.decorators import api_view +from django.http import JsonResponse +from chatbot.utils.kafka_utils import update_profile_in_db, update_project_in_db + + +@api_view(["POST"]) +def sync_user_project_view(request): + body = request.data + user_id = body.get("userId") + if isinstance(user_id, str): + user_id = int(user_id) + profile_data = body.get("profile") + project_data = body.get("projects") + + try: + update_profile_in_db(profile_data=profile_data, user_id=user_id) + update_project_in_db(project_data=project_data) + + return JsonResponse({ + "message": "Data updated successfully.", + "status": "success", + }, status=200) + + except Exception as e: + print("Error: ", e) + return JsonResponse({ + "message": f"An unexpected error occurred: {str(e)}", + "status": "error", + }, status=500) diff --git a/chatbot/views/location_views.py b/chatbot/views/location_views.py new file mode 100644 index 0000000..1e3a624 --- /dev/null +++ b/chatbot/views/location_views.py @@ -0,0 +1,86 @@ +import json +import os +import requests +from rest_framework.decorators import api_view +from rest_framework.response import Response +import json_repair + + +location_auth = os.getenv('LOCATION_AUTH') +location_base_url = os.getenv('LOCATION_BASE_URL') + + +@api_view(['GET']) +def get_location_view(request): + + params = request.query_params + + parent_id = params.get('parentId') + + url = location_base_url + + filters = {"parentId": parent_id} if parent_id else {"type": "state"} + + payload = json.dumps({ + "request": { + "filters": filters + } + }) + headers = { + 'Authorization': f'Bearer ' + location_auth, + 'Content-Type': 'application/json' + } + response = requests.request("POST", url, headers=headers, data=payload) + print("res: ", response) + json_response = response.json() + print("json_response: ", json_response) + location_list = json_response.get('result').get('response') + + return Response({ + 'status': 'ok', + 'list': location_list + }, status=200) + + +@api_view(['GET']) +def get_ip_location_view(request): + try: + #Gget the user's real IP address from headers + ip = None + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] # get the first IP if multiple + + # Fall back to default IP if IP isn't found + if not ip: + return Response({ + 'status': 'error', + 'message': 'Failed! No ip found.' + }) + + print("Client IP:", ip) + + url = f"http://ip-api.com/json/{ip}" + response = requests.get(url, timeout=10) + + if response.status_code == 200: + location_data = response.json() + + location_output = json_repair.repair_json(json.dumps(location_data), return_objects=True) + + return Response({ + 'status': 'ok', + 'location': location_output + }, status=200) + else: + return Response({ + 'status': 'error', + 'message': 'Failed to fetch location' + }, status=response.status_code) + + except Exception as e: + print("Error occurred:", e) + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) diff --git a/chatbot/views/mitra_views.py b/chatbot/views/mitra_views.py new file mode 100644 index 0000000..d30c437 --- /dev/null +++ b/chatbot/views/mitra_views.py @@ -0,0 +1,183 @@ +import traceback +from chatbot.models import Profile, MediaTypeChoices +from chatbot.pdf.knowledge_service.project_report_pdf import generate_project_pdf +from chatbot.utils.shikshalokam_mitra_utils import create_project_utils, create_mitra_project_utils +from rest_framework.decorators import api_view +from rest_framework.response import Response +from chatbot.utils.media_preview.excel_service import generate_and_upload_excel +from chatbot.utils.project_formatting_utils import ( + normalize_sources_from_chunks, + format_project_timeline +) +from chatbot.utils.media_preview.excel_service import ( + generate_excel_file, +) +from chatbot.utils.S3.s3_service import upload_media +from shikshalokam.models import Project + + + +@api_view(['POST']) +def create_project_view(request): + body = request.data + access_token = body.get('access_token') + session = body.get('session') + user_problem_statement = body.get('user_problem_statement') + user_action_steps = body.get('user_action_steps') + project_duration = body.get('project_duration') + project_title = body.get('project_title') + project_objective = body.get('user_objective') + profile_id = body.get('profile_id') + chunks = body.get('chunks') + language = body.get('language') + + sources_list = normalize_sources_from_chunks(chunks) + timeline = format_project_timeline(project_duration) + + project_id = None + program_id = None + response = "" + if access_token: + if not chunks: + return Response({ + 'status': 'error', + 'message': 'Project source cant be empty', + }, status=500) + + response = create_project_utils( + access_token=access_token, user_problem_statement=user_problem_statement, + user_action_steps=user_action_steps, project_title=project_title, + project_duration_weeks=project_duration, chunks=chunks, session=session, + project_objective=project_objective, status='started' + ) + + project_id = response.get('projectId') + program_id = response.get('programId') + + profile = Profile.objects.filter(id=profile_id).first() + + result = create_mitra_project_utils( + profile=profile, + actual_problem_statement=user_problem_statement, + project_title=project_title, + project_duration=project_duration, + project_objective=project_objective, + user_action_steps=user_action_steps, + project_id=project_id, + program_id=program_id, + chunks=chunks, + language=language, + session=session + ) + + pdf_url = None + pdf_filename = "Project_Report.pdf" + project_id = result.get('project_id') if not project_id else project_id + try: + author_name = profile.first_name if profile else "" + location = profile.location if profile and hasattr(profile, '') else "" + + + + pdf_content = generate_project_pdf( + project_title=project_title, + author_name=author_name, + location=location, + problem_statement=user_problem_statement, + objective=project_objective, + timeline=timeline, + action_steps=user_action_steps, + sources=chunks, + language=language, + session=session + ) + + + print("PDF report is generated successfully") + + if pdf_content and result.get('id'): + pdf_filename = f"{project_title}.pdf" if project_title else "Project_Report.pdf" + pdf_filename = "".join( + c for c in pdf_filename if c.isalnum() or c in (' ', '-', '_', '.') + ).replace(' ', '_') + + pdf_url = None + + if pdf_content and result.get("id"): + pdf_media = upload_media( + project_id=result.get("id"), + media_type="pdf", + file_name=pdf_filename, + file_content=pdf_content.read(), + content_type="application/pdf", + ) + + if pdf_media: + pdf_url = pdf_media["url"] + + + + excel_generation_result = generate_excel_file( + project_title=project_title, + author_name=author_name, + location=location, + timeline=timeline, + user_problem_statement=user_problem_statement, + project_objective=project_objective, + user_action_steps=user_action_steps, + sources_list=sources_list, + ) + + excel_url = None + excel_filename = None + + + if excel_generation_result and result.get("id"): + excel_media = upload_media( + project_id=result.get("id"), + media_type="excel", + file_name=excel_generation_result["file_name"], + file_content=excel_generation_result["file"].read(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + if excel_media: + excel_url = excel_media["url"] + excel_filename = excel_media["file_name"] + + + except Exception as e: + print(f"Error generating/uploading PDF: {str(e)}") + traceback.print_exc() + + + media_response = [] + if pdf_url: + media_response.append({ + 'media_type': MediaTypeChoices.PDF, + 'url': pdf_url, + 'file_name': pdf_filename + }) + + if excel_url: + media_response.append({ + 'media_type': MediaTypeChoices.XLSX, + 'url': excel_url, + 'file_name': excel_filename + }) + + if not media_response: + media_response.append({ + 'media_type': MediaTypeChoices.PDF, + 'url': '', + 'file_name': pdf_filename + }) + + + return Response({ + 'status': 'ok', + 'result': response, + 'project_id': project_id, + 'mitra_result': result, + 'media': media_response + }, status=200) diff --git a/chatbot/views/profile_views.py b/chatbot/views/profile_views.py new file mode 100644 index 0000000..f5aba20 --- /dev/null +++ b/chatbot/views/profile_views.py @@ -0,0 +1,56 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from chatbot.utils.elevate.profile_utils import handle_elevate_profile +from chatbot.utils.profile_utils import create_profile_utils + + +@api_view(['POST']) +def create_profile_views(request): + body = request.data + access_token = body.get('access_token') + print("Access token: ", access_token) + + if not access_token: + return Response({ + 'status': 'error', + 'message': 'Access token is required.' + }, status=400) + + profile_details = create_profile_utils(access_token=access_token) + + if not profile_details.get('success'): + return Response({ + 'status': 'error', + 'message': profile_details.get('message', 'Failed to fetch or create profile details.') + }, status=profile_details.get('status_code', 500)) + + return Response({ + 'status': 'ok', + 'profile_details': profile_details.get('data') + }, status=200) + + +# @api_view(['GET']) +# def read_elevate_profile(request): +# access_token = request.headers.get('X-auth-token') +# print("Access token: ", access_token) + +# if not access_token: +# return Response({ +# 'status': 'error', +# 'message': 'Access token is required.' +# }, status=400) + +# profile_details = handle_elevate_profile(access_token=access_token) + +# if not profile_details or not profile_details.get('profileid'): +# return Response({ +# 'status': 'error', +# 'message': 'Failed to fetch or create profile from Elevate.' +# }, status=500) + +# return Response({ +# 'status': 'ok', +# 'profile_details': profile_details +# }, status=200) diff --git a/chatbot/views/recommendation.py b/chatbot/views/recommendation.py new file mode 100644 index 0000000..fac1a73 --- /dev/null +++ b/chatbot/views/recommendation.py @@ -0,0 +1,107 @@ +import os +from django.conf import settings +from jwt import ExpiredSignatureError, InvalidTokenError +from rest_framework.decorators import api_view +import requests +from django.http import JsonResponse +from chatbot.models import Profile +from chatbot.serializer.profile_serializer import ProfileSerializer +from chatbot.utils.profile_utils import create_profile_utils +from shikshalokam.models import Project, ProjectCreatedBy, ProjectVernacular +from shikshalokam.serializer import ProjectSerializer +import jwt +from shikshalokam.utils.recommendation_utils import get_expert_projects + +recommendation_base_url = os.getenv("RECOMMENDATION_BASE_URL") +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + + + +@api_view(["GET"]) +def generate_recommendation(request): + body = request.query_params + limit = body.get("limit") + page = body.get("page", 1) + language = body.get("language") + access_token = request.headers.get("X-auth-token") + + default_response = { + 'result': { + "data": [], + "count": 0 + } + } + + try: + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + user_id = decoded.get("data", {}).get("id") + + if not user_id: + return JsonResponse(default_response, status=200, safe=False) + + except ExpiredSignatureError: + return JsonResponse(default_response, status=200, safe=False) + except InvalidTokenError: + return JsonResponse(default_response, status=200, safe=False) + + try: + res_profile = create_profile_utils(access_token=access_token) + current_profile = Profile.objects.get(userid=user_id) + except Profile.DoesNotExist: + try: + create_profile_utils(access_token=access_token) + current_profile = Profile.objects.get(userid=user_id) + print("Profile successfully created and retrieved.") + except Profile.DoesNotExist: + print("Failed to create or retrieve profile.") + return JsonResponse(default_response, status=200, safe=False) + + try: + current_profile_serialized = ProfileSerializer(current_profile).data + project_templates = get_expert_projects(language=language) + if not project_templates: + return JsonResponse(default_response, status=200, safe=False) + + user_projects = Project.objects.filter(author=current_profile) + user_projects_serialized = ProjectSerializer(user_projects, many=True).data + + data = { + "current_profile": current_profile_serialized, + "project_templates": project_templates, + "user_projects": user_projects_serialized + } + url = recommendation_base_url + response = requests.post(url, json=data) + response.raise_for_status() + + results = response.json() + matched_projects = [] + if results: + matched_projects = results.get('matched_projects') + count = len(matched_projects) + + if limit: + page = int(page) + limit = int(limit) + print("limit: ", limit) + print("page: ", page) + start_index = (page - 1) * limit + end_index = start_index + limit + paginated_projects = matched_projects[start_index:end_index] + else: + paginated_projects = matched_projects + + return JsonResponse({ + 'result': { + "data": paginated_projects, + "count": count + } + }, safe=False) + except Exception as e: + print(f"Error: {e}") + return JsonResponse(default_response, status=200, safe=False) + diff --git a/chatbot/views/story_views.py b/chatbot/views/story_views.py new file mode 100644 index 0000000..019b1d7 --- /dev/null +++ b/chatbot/views/story_views.py @@ -0,0 +1,375 @@ +from chatbot.models import Story, StoryMedia, SessionFlowName +from chatbot.models.base_models import Flow +from chatbot.models.enums import CreateStoryChoices +from chatbot.models.media_models import ProfileMedia +from chatbot.serializer.profile_serializer import ProfileMediaSerializer +from chatbot.serializer.story_serializer import StoryCreateSerializer, StoryRetrieveSerializer, StoryMediaRetrieveSerializer, StoryFullSerializer +from chatbot.utils.recreate_story_utils import re_create_story_object +from chatbot.utils.shikshalokam_story_utils import update_story_pdf +from chatbot.utils.story_utils.base.story_update_utils import extract_update_data, get_or_create_translation, update_translation_fields, sync_to_main_story +from chatbot.utils.story_utils.base.translation_mixins import LanguageDetectionMixin +from chatbot.utils.story_utils.story_utils import create_story_object, generate_story +from django.contrib.auth import PermissionDenied +from rest_framework import generics, status +from rest_framework.decorators import api_view +from rest_framework.response import Response +import django_filters +import traceback +import logging + +logger = logging.getLogger('django') + + +@api_view(['POST']) +def end_story(request): + error_type='generic_error' + try: + profile_id = request.data['profile_id'] + session = request.data['session'] + model = request.data.get('model', None) + access_token = request.data.get('access_token', None) + flow = request.data.get('flow') + language = request.data.get('language', 'en') + + if session is None: + return Response({ + 'status': 'error', + 'message': 'session is mandatory', + 'error_message': 'session is mandatory', + 'error_type': error_type, + }, status=400) + else: + id, content, error_msg, error_type = create_story_object( + profile_id=profile_id, session=session, + access_token=access_token, flow=flow, language=language + ) + + if error_msg: + return Response({ + 'status': 'error', + 'message': error_msg, + 'error_message': error_msg, + 'error_type': error_type, + }, status=400) + + return Response({ + 'status': 'ok', + 'message': 'Story created', + 'id': id, + 'content': content, + }, status=200) + except Exception as e: + traceback.print_exc() + return Response({ + 'status': 'error', + 'message': '', + 'error_message': f'{e}', + 'error_type': error_type, + }, status=500) + + +@api_view(['POST']) +def end_story_v2(request): + error_type='generic_error' + try: + profile_id = request.data['profile_id'] + session = request.data['session'] + access_token = request.headers.get('Authorization', "Bearer: ")[7:] + flow = request.data.get('flow') + language = request.data.get('language', 'en') + + if isinstance(access_token, str): + access_token = access_token.strip() + if access_token == "": + access_token = None + + if session is None: + return Response({ + 'status': 'error', + 'message': 'session is mandatory', + 'error_message': 'session is mandatory', + 'error_type': error_type + }, status=400) + + id, content, error_msg, error_type = generate_story( + profile_id=profile_id, session=session, + access_token=access_token, flow=flow, language=language + ) + if error_msg: + return Response({ + 'status': 'error', + 'message': error_msg, + 'error_message': error_msg, + 'error_type': error_type, + }, status=400) + + return Response({ + 'status': 'ok', + 'message': 'Story created', + 'id': id, + 'content': content, + }, status=200) + except Flow.DoesNotExist: + return Response({ + 'status': 'error', + 'message': '', + 'error_message': "Invalid flow", + 'error_type': error_type + }, status=404) + + except Exception as e: + traceback.print_exc() + return Response({ + 'status': 'error', + 'message': '', + 'error_message': f'{e}', + 'error_type': error_type + }, status=500) + + +class StoryListCreateView(generics.ListCreateAPIView): + queryset = Story.objects.all() + serializer_class = StoryCreateSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['session', 'author'] + + +class StoryRetrieveUpdateDestroyView(LanguageDetectionMixin, generics.RetrieveUpdateDestroyAPIView): + queryset = Story.objects.all() + serializer_class = StoryRetrieveSerializer + + + def partial_update(self, request, *args, **kwargs): + print("Updating (PATCH)") + + story = self.get_object() + language = self.detect_language(request, story) + + if language != 'en': + return self.handle_translation_update(request, language, *args, **kwargs) + else: + return self.handle_update_logic(request, *args, **kwargs, is_partial=True) + + def handle_translation_update(self, request, language, *args, **kwargs): + """Handle updates to story translations AND sync back to main story""" + story = self.get_object() + + try: + # Step 1: Extract and validate request data + update_data = extract_update_data(request) + print("update_data: ", update_data) + # Step 2: Get or create translation + translation = get_or_create_translation(story, language, update_data) + print("translation: ", translation) + + # Step 3: Update translation with new data + update_translation_fields(translation, update_data, language) + print("update_translation_fields done") + + # Step 4: Sync back to main story (English) + sync_to_main_story(story, translation, update_data, language) + print("sync_to_main_story done") + + # Step 5: Generate response and handle post-update tasks + return self._generate_update_response(request, story, update_data) + + except Exception as e: + print(f"Error while handle_translation_update {e}") + return self._handle_update_error(e) + + def _generate_update_response(self, request, story, update_data): + """Generate response and handle post-update tasks""" + # Generate serialized response + serializer = self.get_serializer(story, context={'request': request}) + response_data = serializer.data + + # Handle PDF update if needed + if update_data.get('session') and update_data.get('flow'): + update_story_pdf( + access_token=update_data['access_token'], + session=update_data['session'], + flow=update_data['flow'], + is_edit_story=True + ) + + return Response(response_data, status=status.HTTP_200_OK) + + def _handle_update_error(self, error): + """Handle update errors consistently""" + print(f"Error updating translation: {str(error)}") + traceback.print_exc() + return Response({ + 'error': f'Failed to update translation: {str(error)}' + }, status=status.HTTP_400_BAD_REQUEST) + + + def handle_update_logic(self, request, *args, **kwargs): + """Handle English story updates""" + is_partial = kwargs.pop('is_partial', False) + session_value = request.data.get('session') + access_token = request.data.get('access_token') + flow = request.data.get('flow') + + try: + if is_partial: + response = super().partial_update(request, *args, **kwargs) + if response and response.status_code in [status.HTTP_200_OK, status.HTTP_204_NO_CONTENT]: + update_story_pdf( + access_token=access_token, session=session_value, flow=flow, + is_edit_story=True + ) + return response + except Exception as e: + print("Error occurred: ", str(e)) + raise + + +class StoryMediaListCreateView(generics.ListCreateAPIView): + queryset = StoryMedia.objects.all() + serializer_class = StoryMediaRetrieveSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['story'] + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def create(self, request, *args, **kwargs): + """ + Handle POST requests (create). + """ + print("Creating") + session_value = request.data.get('session') + access_token = request.data.get('access_token') + flow = request.data.get('flow') + file_url = request.data.get('file_url') + + if file_url is not None and file_url.startswith("s3://"): + file_url = "https://" + file_url[len("s3://"):] + request.data["file_url"] = file_url + + print("session_value: ", session_value) + print("flow: ", flow) + print("access_token: ", access_token) + try: + response = super().create(request, *args, **kwargs) + print("response: ", response) + print("response status_code: ", response.status_code) + + if response.status_code == status.HTTP_201_CREATED and flow != SessionFlowName.Reflection: + update_story_pdf( + access_token=access_token, session=session_value, flow=flow + ) + return response + + except Exception as e: + logger.error("Error: %s", e, exc_info=True) + raise + + +class StoryMediaRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView): + queryset = StoryMedia.objects.all() + serializer_class = StoryMediaRetrieveSerializer + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def partial_update(self, request, *args, **kwargs): + """ + Handle PATCH requests for partial updates. + """ + print("Updating (PATCH)") + return self.handle_update_logic(request, *args, **kwargs, is_partial=True) + + def update(self, request, *args, **kwargs): + """ + Handle PUT requests for full updates. + """ + print("Updating (PUT)") + return self.handle_update_logic(request, *args, **kwargs, is_partial=False) + + def handle_update_logic(self, request, *args, **kwargs): + """ + Shared logic for PUT and PATCH requests. + """ + is_partial = kwargs.pop('is_partial', False) # Safely extract the flag + session_value = request.data.get('session') + access_token = request.data.get('access_token') + flow = request.data.get('flow') + print("session_value: ", session_value) + print("flow: ", flow) + print("access_token: ", access_token) + + try: + if is_partial: + response = super().partial_update(request, *args, **kwargs) + else: + response = super().update(request, *args, **kwargs) + + print("response: ", response) + print("response status_code: ", response.status_code) + + if (response.status_code in [status.HTTP_200_OK, status.HTTP_204_NO_CONTENT] and + flow != SessionFlowName.Reflection): + update_story_pdf( + access_token=access_token, session=session_value, flow=flow + ) + return response + except Exception as e: + print("Error occurred: ", str(e)) + raise + + +class ProfileMediaListCreateView(generics.ListCreateAPIView): + queryset = ProfileMedia.objects.all() + serializer_class = ProfileMediaSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['profile'] + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + +class ProfileMediaRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView): + queryset = ProfileMedia.objects.all() + serializer_class = ProfileMediaSerializer + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + +@api_view(['POST']) +def story_recreate_view(request): + profile_id = request.data.get('profile_id') + session_id = request.data.get('session_id') + print('profile_id: ', profile_id) + print('session_id: ', session_id) + if profile_id is None or session_id is None: + return Response({'error': 'Profile ID and Session ID are required.'}, status=status.HTTP_400_BAD_REQUEST) + + story_id, story_content = re_create_story_object(profile_id, session_id) + temp_json = { + 'id': story_id, + 'content': story_content + } + + return Response({'message': temp_json}, status=status.HTTP_200_OK) + + +class StoryBySessionView(generics.ListAPIView): + serializer_class = StoryFullSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['session'] + + def get_queryset(self): + session_id = self.request.query_params.get('session') + if session_id: + return Story.objects.filter(session=session_id) + return Story.objects.none() diff --git a/config/__init__.py b/config/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd5146a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: mohini-postgres + env_file: + - .env.postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis for caching and channels + redis: + image: redis:7-alpine + container_name: mohini-redis + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # Gotenberg for document conversion + gotenberg: + image: gotenberg/gotenberg:8 + container_name: mohini-gotenberg + ports: + - "3003:3000" + + # Django Backend Service + backend: + image: shikshalokam-mohini-service + container_name: shikshalokamqa/mitra-backend + command: > + sh -c "python manage.py create_schemas && python manage.py migrate chatbot 0066 --fake && python manage.py migrate && + uvicorn shikshalokam_mohini.asgi:application --host 0.0.0.0 --port 9000 --workers 4 --ws-ping-interval 30 --ws-ping-timeout 600" + env_file: + - .env.backend + ports: + - "9000:9000" + volumes: + - logs:/app/shikshalokam-mohini-service/logs + - ./secrets.json:/app/backend/config/secrets.json:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/health/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Celery Worker + celery-worker: + image: shikshalokam-mohini-service + container_name: mohini-celery-worker + command: celery -A shikshalokam_mohini worker + env_file: + - .env.backend + volumes: + - logs:/app/shikshalokam-mohini-service/logs + - ./secrets.json:/app/backend/config/secrets.json:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + logs: + + +networks: + backend: + driver: bridge diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..251f043 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/apps/chatbot/chatbot_admin.md b/docs/apps/chatbot/chatbot_admin.md new file mode 100644 index 0000000..6136dec --- /dev/null +++ b/docs/apps/chatbot/chatbot_admin.md @@ -0,0 +1,21 @@ +# Chatbot Admin Module + +## Overview + +The `admin` module contains Django admin customizations facilitating management of chatbot configurations and content through the Django Admin interface. + +## Key Admin Modules + +- `bot_vernacular_admin.py`: Admin configurations for bot vernacular settings allowing customization of bot messages per bot and locale. +- `company_admin.py`: Admin setup for managing Company entities. +- `generic_upload_admin.py`: Provides generic CSV bulk upload functionality for admin models enabling structured batch data ingestion. +- `media_admin.py`: Admin interface customizations for managing media entries related to chatbot content. +- `profile_admin.py`: Admin configurations for managing user profile data. +- `story_admin.py`: Admin setups for Story management including story content and media attachments. +- `theme_admin.py`: Admin customizations for theme management, likely affecting UI and style aspects. + +## Purpose + +These admin customizations provide a user-friendly and efficient UI overlay that simplifies managing core chatbot configurations, content, and metadata by directly manipulating the database through Django's ORM. + +They support bulk uploading, content review, and entity configuration essential for daily operational management. \ No newline at end of file diff --git a/docs/apps/chatbot/chatbot_auth.md b/docs/apps/chatbot/chatbot_auth.md new file mode 100644 index 0000000..0609692 --- /dev/null +++ b/docs/apps/chatbot/chatbot_auth.md @@ -0,0 +1,28 @@ +# Chatbot Authentication + +## Overview + +The authentication layer in the Chatbot app is responsible for validating user identity, securing access, and managing JWT tokens. + +## ProfileJWTAuthentication Class + +Located in `chatbot/auth.py`, this class extends JWTAuthentication from rest_framework_simplejwt to: + +- Authenticate users based on JWT tokens. +- Retrieve user profile information from the `Profile` model. +- Ensure blacklisted tokens cannot be used. + +### Key Methods + +- `authenticate(request)`: Verifies presence of Authorization header, validates token, checks blacklist. +- `get_user(validated_token)`: Extracts the user from the token, raises errors if token invalid or user not found. + +### Token Blacklisting + +- Utilizes `BlacklistedToken` model. +- Checks if token is blacklisted and denies authentication if so. + +## Interaction + +- Integrated directly with Django Rest Framework authentication flow. +- Used across chatbot endpoints for secure access. diff --git a/docs/apps/chatbot/chatbot_celery_tasks.md b/docs/apps/chatbot/chatbot_celery_tasks.md new file mode 100644 index 0000000..6cd82eb --- /dev/null +++ b/docs/apps/chatbot/chatbot_celery_tasks.md @@ -0,0 +1,55 @@ +# Chatbot Celery Tasks + +## Overview + +Celery tasks support asynchronous execution of chatbot workloads allowing responsive UX and offloading heavy operations. + +## Key Celery Task Modules + +### handle_message.py + +- Utility methods for sending and translating messages on websocket channels. +- Utilizes Django Channels for WebSocket integration. + +### chaupal_tasks.py + +- Implements tasks for managing Chaupal style guest discussions, including flow control and session updates. + +### common_chat_tasks.py + +- Contains common chatbot task logic such as saving chat messages to DB. + +### flow_tasks.py + +- Manages chatbot flow processing tasks. + +### free_flow_tasks.py + +- Handles free form chatbot interactions asynchronously. + +### guided_guest_tasks.py + +- Supports guided guest chat flow tasks. + +### mitra_bedrock_tasks.py, one_shot_bedrock_tasks.py, reflection_bedrock_tasks.py, shikshalokam_bedrock_tasks.py + +- Integrate with Bedrock LLM services for various bot requirements. + +### oneshot_guest_tasks.py + +- Dedicated handling for one shot guest chatbot conversations. + +### post_processing_tasks.py + +- Handles operations executed after primary task completion. + +### ptm_report_tasks.py + +- Specific to PTM reporting workflows. + +## Interaction + +- Celery tasks are invoked primarily by websocket consumers and service layers. +- They ensure non-blocking operations and scalability. + +--- diff --git a/docs/apps/chatbot/chatbot_consumers.md b/docs/apps/chatbot/chatbot_consumers.md new file mode 100644 index 0000000..c763b05 --- /dev/null +++ b/docs/apps/chatbot/chatbot_consumers.md @@ -0,0 +1,33 @@ +# Chatbot WebSocket Consumers + +## Overview + +The `consumers` module manages real-time WebSocket connections for the chatbot, enabling continuous interactive chat experiences. It handles message receipt, session management, and asynchronous processing initiation. + +## Primary Consumer + +### AsyncSocketConsumer + +- Located in `chatbot/consumers/async_consumer.py`. +- Extends `AsyncBaseConsumer` to implement core WebSocket lifecycle methods: connect, disconnect, and receive. +- Key features include: + - Session and profile initialization on authentication messages. + - Background task management using Celery for handling chat flow responses. + - Message translation capabilities based on user-selected languages. + - Asynchronous database operations for session creation and message logging. + +## Other Consumer Modules + +The `consumers` directory includes specialized consumers targeting different chatbot flows and LLM providers: + +- **async_chaupal_consumer.py**: Handles Chaupal style guest discussion bots with long-running conversational contexts. +- **async_base_consumer.py**: Base class providing common WebSocket async consumer utilities. +- **base_consumer.py**: Synchronous base consumer class. +- **chaupal_consumer.py**: Synchronous consumer for Chaupal bots. +- **free_flow_consumer.py**: Handles free-form chatbot conversations. +- **guided_guest_consumer.py**: Manages guided guest chatbot interactions. +- **mitra_bedrock_consumer.py**, **one_shot_bedrock_consumer.py**, **shikshalokam_bedrock_consumer.py**: Integrations with Bedrock LLM provider for different bot types. +- **oneshot_guest_consumer.py**: One-shot guest chatbot conversation handling. +- **Reflection_bedrock_consumer.py**: Reflection bot using Bedrock. + +Each specialized consumer adapts websocket interactions tailored for the specific chatbot flow or LLM provider use case. diff --git a/docs/apps/chatbot/chatbot_form.md b/docs/apps/chatbot/chatbot_form.md new file mode 100644 index 0000000..f5cc306 --- /dev/null +++ b/docs/apps/chatbot/chatbot_form.md @@ -0,0 +1,17 @@ +# Chatbot Form Module + +## Overview + +The chatbot `form` module contains Django form classes primarily used for input validation and processing within the chatbot admin and application UI. + +## MediaAdminForm + +Located in `form/media/media_form.py`, the `MediaAdminForm` class: + +- A Django ModelForm for the `Media` model. +- Manages manual and auto tags with custom multiple choice fields. +- Supports filtered selection widgets for user-friendly tag selection. +- Handles saving and associating tags, preserving AI-generated tags while allowing manual tag updates. +- Includes logic for populating fields according to existing model instances or initializing new ones. + +This form enables efficient tag management and validation in media-related chatbot admin workflows. diff --git a/docs/apps/chatbot/chatbot_management.md b/docs/apps/chatbot/chatbot_management.md new file mode 100644 index 0000000..d9d1f7e --- /dev/null +++ b/docs/apps/chatbot/chatbot_management.md @@ -0,0 +1,24 @@ +# Chatbot Management Commands + +## Overview + +The chatbot management commands serve two primary purposes to facilitate setup and administration: + +### 1. Schema Creation (`create_schemas.py`) + +- This command creates PostgreSQL database schemas automatically based on environment variables. +- It avoids manual database login and setup by reading a comma-separated list of schemas from the `POSTGRES_SCHEMAS` env var or command arguments. +- Intended to be run once before initial migrations. +- Validates schema names to prevent SQL injection. + +### 2. Initial Database Preparation (`prepare_db.py`) + +- Prepares the database for chatbot operations by: + - Creating missing schemas (reuses create_schemas internally). + - Creating or updating a primary Company record with id=1 using env-configured name and slug. + - Creating or updating an admin Profile with id=1 including default admin email. +- Includes additional setup for a "Null User" Profile for system use. + +Together, these commands streamline first-time database setup and ensure essential company and admin records exist for the chatbot to function properly. + +--- diff --git a/docs/apps/chatbot/chatbot_pdf.md b/docs/apps/chatbot/chatbot_pdf.md new file mode 100644 index 0000000..ad4a6e2 --- /dev/null +++ b/docs/apps/chatbot/chatbot_pdf.md @@ -0,0 +1,20 @@ +# Chatbot PDF Generation + +## Overview + +This module primarily provides the HTML layout, CSS styling, and template structure used in creating PDFs as part of story creation workflows. + +## Key PDF Modules + +- `story_first_page.py`: Defines the layout and structure for the first page of story PDFs. +- `story_secondpage.py`: Defines the second page layout. +- `story_thirdpage.py`: Defines the third page layout. +- `story_images_page.py`: Manages story image pages within PDFs. +- `story_pdf.css`: Contains CSS controlling PDF styles and formatting. + +## Purpose + +- Provides reusable page layouts and styles for story PDF generation. +- Supports producing consistent, styled documents from narrative data for export or archival. + +This module is used within story creation flows to render multi-page, styled PDFs reflecting chatbot-generated stories and content. \ No newline at end of file diff --git a/docs/apps/chatbot/chatbot_scripts.md b/docs/apps/chatbot/chatbot_scripts.md new file mode 100644 index 0000000..41eb355 --- /dev/null +++ b/docs/apps/chatbot/chatbot_scripts.md @@ -0,0 +1,17 @@ +# Chatbot Scripts + +## Overview + +This documents utility scripts located in the `chatbot/scripts/` directory that support various maintenance, data processing, and extraction workflows. + +## Key Scripts + +- `generate_models_docs.py`: Generates documentation for data models. +- `sync_media_to_vector_db.py`: Synchronizes media assets to a vector database for search capabilities. +- `qdrant_sanity_check.py`: Performs integrity checks on Qdrant vector database. +- `theme_extraction.py` and `theme_extraction_in_file.py`: Extract thematic data from input sources. + +## Usage + +These scripts serve as development, migration, and data management tools complementing runtime chatbot functionalities. +They can be executed standalone to perform targeted operations required by the application lifecycle. diff --git a/docs/apps/chatbot/chatbot_serializer_and_filter.md b/docs/apps/chatbot/chatbot_serializer_and_filter.md new file mode 100644 index 0000000..a196b96 --- /dev/null +++ b/docs/apps/chatbot/chatbot_serializer_and_filter.md @@ -0,0 +1,37 @@ +# Chatbot Serializers and Filters + +## Overview + +This document covers the serializers and filters modules within the chatbot application, which play crucial roles in data transformation, validation, and querying. + +## Serializers + +Serializers handle the conversion between complex data types like Django models and JSON representations used in REST APIs. They also encapsulate input validation logic. + +### Key Serializer Modules + +- `base_serializer.py`: Base serializers providing common functionality. +- `media_serializer.py`: Serializers related to media models. +- `company_serializer.py`: Serializers for company-related data. +- `profile_serializer.py`: Handles profile model serialization and validation. +- `story_serializer.py`: Serializes Story entities. + +## Filters + +The filters in the chatbot are primarily used for filtering functionality within the Django admin interface, supporting admin users in querying and managing data efficiently. + +### Key Filter Modules + +- `admin_filter.py`: Provides core filtering capabilities customized for the admin panel. +- `media_filters.py`: Support filtering on Media models for admin views. +- `flow_filter.py`: Implements filters for chatbot flow related admin queries. +- `story_filter.py`: Enables filtering of Story records in admin. +- `drf_filter.py`: Contains filters that may be used internally by views or admin. +- `custom_date_from_filter.py`: Provides specialized date filters for admin usage. + +## Interaction + +- Serializers handle API input/output data transformations and validations. +- Filters focus mainly on easing data management in admin UI by providing reusable constraints. + +Together, serializers and filters establish reliable backend data handling and flexible admin querying capabilities. diff --git a/docs/apps/chatbot/chatbot_services.md b/docs/apps/chatbot/chatbot_services.md new file mode 100644 index 0000000..112f144 --- /dev/null +++ b/docs/apps/chatbot/chatbot_services.md @@ -0,0 +1,95 @@ +# Chatbot Core Services + +## Overview + +The chatbot core services coordinate essential functions like session management, message preparations, prompt building, and orchestration of chatbot workflows to provide responsive conversational experiences. + +## Key Services in Detail + +### BaseChatService + +The BaseChatService handles shared operations essential for chatbot function: + +- Manages database queries to fetch session-related data such as chat messages, user Profile, chat sessions, and bot configurations. +- Retrieves bot vernacular settings to produce personalized introductory messages including user first name injection. +- Extracts detailed user profile info, eg. location, for use in conversation customization and context. + +### ChatOrchestrator + +The ChatOrchestrator serves as the central controller managing overall chat processing: + +- Utilizes BaseChatService to gather necessary session data. +- Prepares and filters messages to be used in responses. +- Delegates session processing to a designated bot strategy based on bot type. +- Constructs system prompts tailored for the language model provider in use. +- Handles chat response collection and logs output. +- Includes error handling mechanisms to ensure robust processing. + +### MessageHandler + +This service focuses on efficiently preparing chatbot messages: + +- Prepares message sets combining chats and introductory prompts. +- Filters chats further using state machine configuration for precise message scope. + +### PromptBuilder + +Responsible for generating system prompt content: + +- Builds concatenated prompts comprising bot context, state machine context, and completion criteria. +- Formats prompts differently based on large language model provider (Bedrock, OpenAI, etc.). + +### BotServiceFactory + +Employs factory pattern for creating bot strategy instances: + +- Supports known bot strategies including 'oneshot', 'guided_guest', 'guest_discussion', and 'common'. +- Allows dynamic extension by registering additional bot strategy classes. + +This service layer delivers the foundation enabling versatile chatbot operations supporting multiple interaction designs. + +The core services in the chatbot app encapsulate essential functionalities required to manage chat sessions, prepare messages, build prompts, and orchestrate the chatbot's workflow. + +These services are primarily located in `chatbot/services/core/`. + +## BaseChatService + +- Provides shared database operations like fetching session data, user profile, and bot vernacular. +- Methods: + - `get_session_data(session_id, profile_id, bot_route)`: Fetches company chats, chat session, profile, and company bot. + - `get_bot_vernacular_and_intro(company_bot, profile)`: Retrieves bot vernacular and generates introductory messages. + - `get_user_profile_info(profile)`: Extracts user profile information like name and location. + +## ChatOrchestrator + +- Central orchestrator for chat processing. +- Interacts with services/core components and bot strategies. +- Main method: `process_chat_request(channel_name, session_id, profile_id, language)` handles the chat session processing flow including: + - Fetching session data + - Preparing messages + - Processing session with strategy + - Filtering messages + - Building prompts + - Getting responses from strategy + - Handling errors + +## MessageHandler + +- Responsible for message preparation and filtering. +- Methods: + - `prepare_messages(company_bot, company_chats, intro_mssg, other_info)`: Prepares and formats messages. + - `get_filtered_chats(session_id, state_machine, company_chats)`: Fetches chats filtered by state machine if required. + +## PromptBuilder + +- Builds system prompts for use with large language model providers. +- Supports different LLM providers with tailored prompt formats. +- Method: `build_system_prompt(company_bot, state_machine)` + +## BotServiceFactory + +- Factory class to instantiate the appropriate bot strategy based on bot type. +- Maps bot types like 'oneshot', 'guided_guest', 'guest_discussion', and 'common' to their strategy classes. +- Methods: + - `create_bot_service(bot_type, route=None, extra_params=None)` + - `register_strategy(bot_type, strategy_class)` to add new strategies. diff --git a/docs/apps/chatbot/chatbot_strategies.md b/docs/apps/chatbot/chatbot_strategies.md new file mode 100644 index 0000000..639dbb9 --- /dev/null +++ b/docs/apps/chatbot/chatbot_strategies.md @@ -0,0 +1,41 @@ +# Bot Strategies + +## Overview + +The chatbot employs multiple bot strategies, each implementing distinct conversational flows tailored for specific use cases. All strategies inherit from the abstract `BotStrategy` base class located in `services/strategies/base_strategy.py`. + +## Strategies Implemented + +### CommonBotStrategy (Primary Strategy) + +The CommonBotStrategy serves as the foundational strategy and is the default for any chatbot flow that does not require specialized behavior or customization. It is the backbone for all generic and future chatbot conversations. + +- Uses the "common" response handler type. +- Processes the session by retrieving the current state machine step associated with the chat session. +- Offers extensibility to support a wide range of chatbot flows without the need for separate custom strategies. + +### GuestDiscussionBotStrategy + +This strategy is developed specifically for guest discussion or chaupal style bots. + +- Default route set to `/shikshalokam_chaupal`. +- Processes the session using the relevant state machine tied to the current chat step. + +### GuidedGuestBotStrategy + +Tailored for guided guest chatbot interactions. + +- Default route is `/guided_guest`. +- Similar session processing via state machine retrieval. +- Includes placeholders for potential future enhancement like step increments for new users. + +### OneShotBotStrategy + +Designed for one-shot conversation flows that follow pre-defined stages. + +- Default route is `/oneshot_guest`. +- Determines the remaining stages in the conversation through utility methods. +- Updates the chat session's current step according to the state machine. +- Implements stage filtering based on user profile data. + +This structured approach to bot strategies allows the chatbot to maintain a modular, extensible architecture supporting multiple conversational patterns while sharing a unified interface. diff --git a/docs/apps/chatbot/chatbot_templates.md b/docs/apps/chatbot/chatbot_templates.md new file mode 100644 index 0000000..684c88d --- /dev/null +++ b/docs/apps/chatbot/chatbot_templates.md @@ -0,0 +1,41 @@ +# Chatbot Templates + +## Overview + +The chatbot application includes several HTML templates primarily used within the Django admin interface to facilitate batch media upload and generic batch imports associated with chatbot data. + +## Key Templates + +### Batch Upload Templates + +Stored under `templates/admin/batch_upload/`, these templates support a guided, multi-step workflow for batch uploading media: + +- `batch_upload.html`: Main batch upload page extending the admin base template. + - Includes steps for file upload, review, and saving. + - Dynamically loads JavaScript and CSS required for upload functionality. + - Uses included sub-templates: + - Step indicators + - Status messages + - Upload, Review, and Save step content sections + +- Supporting partial templates like: + - `includes/step_indicator.html` + - `includes/status_messages.html` + - `steps/step1_upload.html`, `steps/step2_review.html`, `steps/step3_save.html` + +### Generic Batch Upload Template + +- `generic_batch_upload.html` + - Supports batch importing for generic chatbot models. + - Provides a step-based UI with progress indicators and field selections. + - Includes embedded CSS for styling and JavaScript integration. + +### Template Usage Context + +These templates are primarily used in the Django admin UI for chatbot media and data management, enabling administrators to perform bulk imports and uploads with validation and review steps. + +They provide a smooth UI experience to upload or import data sets, review them, and process saving actions, essential for managing chatbot content and configurations. + +--- + +This complements backend chatbot functionalities with admin interface tools for effective management of media and model data. diff --git a/docs/apps/chatbot/chatbot_translate.md b/docs/apps/chatbot/chatbot_translate.md new file mode 100644 index 0000000..bb0475a --- /dev/null +++ b/docs/apps/chatbot/chatbot_translate.md @@ -0,0 +1,20 @@ +# Chatbot Translation Layer + +## Overview + +The `translate` folder contains modules that facilitate text translation, transliteration, and language transformation services which are crucial for supporting multilingual chatbot interactions. + +## Core Components + +- Provides abstractions to select translation and speech providers dynamically based on bot configuration. +- Contains utility functions to handle text transliteration and conversions. +- Supports integration with external language services such as AI4Bharat. + +## Purpose + +- Enables chatbots to communicate seamlessly in multiple languages. +- Provides endpoint support through views and consumers for real-time translation. +- Centralizes language processing to maintain consistent API responses and formats. + +This layer underpins the chatbot's multilingual capabilities enhancing accessibility and user experience. +For detailed provider-level implementation and external integrations, see the [Backend Translation Integrations](../../backend/translate.md). diff --git a/docs/apps/chatbot/chatbot_urls.md b/docs/apps/chatbot/chatbot_urls.md new file mode 100644 index 0000000..63b1765 --- /dev/null +++ b/docs/apps/chatbot/chatbot_urls.md @@ -0,0 +1,35 @@ +# Chatbot URLs and Routing + +## Overview + +The chatbot application exposes several HTTP API endpoints and WebSocket routes to facilitate various chatbot functionalities. + +## HTTP URL Routing + +- Defined in `chatbot/urls.py`. +- Uses Django REST Framework views for API endpoints. +- Endpoints support profile management, chat sessions, media handling, transcription, translation, story management, and more. +- Includes both standard RESTful routes and specialized views for media batch operations, tracking, and document uploads. + +### Example Endpoints + +- `/api/profile/`: Create or update user profile. +- `/api/login/`: Login endpoint. +- `/api/save-company-chat/`: Save chat messages. +- `/api/chatsession/`: Manage chat sessions. +- `/api/text_translate/`: Text translation API. + +## WebSocket Routing + +- Defined in `chatbot/routing.py`. +- Maps WebSocket URL patterns to specialized consumer classes handling different chatbot conversational models. +- Examples: + - `ws/common/` mapped to `AsyncSocketConsumer` for standard chats. + - `ws/shikshalokam_chaupal/` for Chaupal-specific bots. + - `ws/guided_guest/`, `ws/free_flow/`, and others. + +## Summary + +This routing setup provides comprehensive access to chatbot functionalities both synchronously over HTTP and asynchronously via WebSocket for real-time communication. + +--- diff --git a/docs/apps/chatbot/chatbot_utils.md b/docs/apps/chatbot/chatbot_utils.md new file mode 100644 index 0000000..52a6c04 --- /dev/null +++ b/docs/apps/chatbot/chatbot_utils.md @@ -0,0 +1,26 @@ +# Chatbot Utilities + +## Overview + +The utilities module contains many helper functions and classes that support various chatbot operations ranging from audio processing, translation, data handling, to LLM integrations. + +## Key Utility Files and Functions + +- `chat_utils.py`: Utilities related to chatbot message processing and guided chat generation. +- `one_shot_utils.py`: Helpers for managing one-shot bot stages and interactions. +- `audio_provider_utils.py`: Functions for handling audio provider integrations including text-to-text translation. +- `transliterate_utils.py`: Supports text transliteration used in chat translations. +- `profile_utils.py`: Helpers to deal with user profile data extraction and formatting. +- `llm.py`: Instruments interactions with Large Language Model providers. + +## Additional Utilities + +- Tools for working with specific bot flows (e.g., `bedrock_tool_call.py`, `chaupal_tool_call.py`, `oneshot_guest_tool_call.py`). +- Converters for audio and image processing. +- Utilities for database access, environment parsing, Kafka messaging, and story recreation. + +## Usage + +- These utilities are imported and used strategically across services, consumers, and celery tasks for streamlined logic and code reuse. + +--- diff --git a/docs/apps/chatbot/models.md b/docs/apps/chatbot/models.md new file mode 100644 index 0000000..53800d1 --- /dev/null +++ b/docs/apps/chatbot/models.md @@ -0,0 +1,1369 @@ +# Django Models + +`chatbot/models/` + +This layer defines the complete database schema for the chatbot platform. + +It manages persistence, relationships, constraints, indexing, and domain-level behavior across users, bots, conversations, content, media, and configuration. + +--- + +Responsibilities of this Layer + +- Define core domain entities (User, Bot, Story, Media, etc.) +- Maintain relational integrity using ForeignKeys and constraints +- Enforce validation rules and uniqueness constraints +- Store multilingual and vernacular content +- Manage conversation state and session tracking +- Support knowledge base document storage and vector indexing +- Enable tagging and categorization +- Maintain historical tracking using `simple_history` +- Provide model-level helper methods for business logic +- Use enums for consistent state definitions + +--- + +## 1. BlacklistedToken + +`chatbot/models/auth_models.py` + +### Purpose + +Stores authentication tokens that have been invalidated or revoked. + Used to prevent blacklisted tokens from being reused. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| token | TextField (unique=True, required) | | +| blacklisted_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_blacklisted_at()` +- `get_previous_by_blacklisted_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 2. BotVernacular + +`chatbot/models/bot_vernacular_model.py` + +### Purpose + +Stores language-specific (vernacular) configurations for a company bot. + Allows customized introductory and error messages per language. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| company_bot | ForeignKey (ForeignKey → CompanyBot) | | +| language | CharField (required, max_length=250) | Language code, Example for English use en. | +| introductory_message | TextField () | Provide an introductory message that the bot will present when the conversation starts. | +| alt_introductory_message | TextField () | Provide an alternate introductory message that the bot will present when the conversation starts. | +| name | CharField (max_length=100) | Enter the name of the bot. | +| error_message | TextField () | Provide an error message that the bot will display. | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 3. ChatSession + +`chatbot/models/chat_models.py` + +### Purpose + +Represents an active chat session between a user profile and a company bot. + Stores session metadata, conversation state, and handles title generation using LLMs. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| session | CharField (unique=True, required, max_length=255) | | +| profile | ForeignKey (ForeignKey → Profile) | | +| company_bot | ForeignKey (ForeignKey → CompanyBot) | | +| language | CharField (required, max_length=1000, choices) | | +| title | CharField (max_length=255) | | +| summary | TextField () | | +| current_step | IntegerField () | | +| session_context | JSONField () | | +| session_status | CharField (max_length=20, choices) | | +| project_id | CharField (max_length=400) | | +| user_id | CharField (max_length=400) | | +| session_type | CharField (max_length=100, choices) | | +| other_params | JSONField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_language_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_session_status_display()` +- `get_session_type_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_title()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 4. Company + +`chatbot/models/company_models.py` + +### Purpose + +Represents a company that owns and manages chatbot configurations. + Stores company details like name, slug, status, and logo. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (required, max_length=100) | | +| slug | CharField (unique=True, required, max_length=100) | | +| status | CharField (required, max_length=20, choices) | | +| url | URLField (max_length=200) | | +| logo | ImageField (max_length=1000) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_file_upload_path()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_public_url()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 5. CompanyBot + +`chatbot/models/company_models.py` + +### Purpose + +Defines a chatbot configuration for a specific company. + Stores LLM settings, prompts, provider details, and behavior controls. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (required, max_length=100) | Enter the name of the bot. | +| company | ForeignKey (required, ForeignKey → Company) | Select the company this bot belongs to. | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| context | TextField (required) | Provide the bot's main prompt or description of its purpose. | +| max_token | IntegerField (required) | | +| bot_temperature | FloatField (required) | Set the temperature for controlling response randomness (0-1). Lower values produce more deterministic responses. | +| top_k | IntegerField (required) | Set the top-k value for the bot's response selection. This defines how many top options to consider for each response. | +| provider | CharField (required, max_length=100, choices) | Select the LLM provider (BEDROCK, BEDROCK_CONVERSE, or OPENAI) | +| provider_keys | TextField (required, max_length=1000) | API keys or credentials for the selected LLM provider. | +| llm_model | CharField (required, max_length=100, choices) | Select the LLM model to be used by the bot (e.g., GPT-4o, GPT-4). | +| filter_score | FloatField (required) | Set the filter score for bot response selection (0-1). Responses below this score will be filtered out. | +| end_context | TextField () | Provide additional prompt or context to append at the end of the main prompt to guide the conversation | +| introductory_message | CharField (max_length=1000) | Provide an introductory message that the bot will present when the conversation starts. | +| tag_context | TextField () | Provide any information or context related to variables (like Python-bound variables) that will be inserted into the prompt. | +| route | CharField (required, max_length=100) | Specify the route or API endpoint for interacting with the bot. | +| bot_type | CharField (required, max_length=30, choices) | | +| llm_key | CharField (max_length=255) | | +| dynamic_context | TextField () | Provide dynamic context that can be adjusted during the bot's interactions, such as personalized data. | +| dynamic_context_type | CharField (max_length=20, choices) | | +| pre_context | TextField () | Provide pre-context that will be set before the main prompt to shape the conversation. | +| tool_context | TextField () | | +| other_params | JSONField () | | +| connect_timeout | FloatField (required) | Timeout in seconds for establishing a LLM connection. | +| read_timeout | FloatField (required) | Timeout in seconds for reading a LLM response. | +| chat_history_limit | IntegerField (required) | Controls how many of the most recent chat messages are included as conversation history when making an LLM request. | +| stream | BooleanField (required) | Enable streaming mode for LLM responses. | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_bot_type_display()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_dynamic_context_type_display()` +- `get_file_upload_path()` +- `get_llm_model_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_provider_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 6. CompanyChat + +`chatbot/models/company_models.py` + +### Purpose + +Represents a chat message exchanged between a user and a company bot. + Stores message content, session data, metadata, and optional attachments. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| message | TextField (required) | | +| translated_message | TextField () | | +| chunks | TextField () | | +| sender | ForeignKey (ForeignKey → Profile) | | +| receiver | ForeignKey (ForeignKey → Profile) | | +| session | CharField (required, max_length=255) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| status | CharField (max_length=20, choices) | | +| feedback | CharField (max_length=20, choices) | | +| source | CharField (required, max_length=20, choices) | | +| source_msg_id | CharField (max_length=256) | | +| whatsapp_message_id | CharField (max_length=255) | | +| message_type | CharField (max_length=20) | | +| stage | CharField (max_length=500) | | +| other_params | JSONField () | | +| audio_file | FileField (max_length=1000) | | +| file_url | CharField (max_length=2000) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_feedback_display()` +- `get_file_upload_path()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_source_display()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 7. CompanyStateMachine + +`chatbot/models/company_models.py` + +### Purpose + +Represents a step in a structured conversational workflow for a company bot. + Defines stage logic, prompts, and optional pre/post processing rules. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| company_bot | ForeignKey (required, ForeignKey → CompanyBot) | | +| name | CharField (required, max_length=100) | Enter the name of the state. | +| step | IntegerField (required) | Integer representing the order in which state function calling happens. Lower values are called first. | +| use_stage_chats | BooleanField (required) | If True, only chats from this stage will be included and passed to the LLM. | +| type | CharField (required, max_length=10, choices) | Specify whether the state is mandatory or optional. | +| text_conversion_type | CharField (required, max_length=15, choices) | Choose how to process this field's text: 'Translation' converts meaning into another language, 'Transliteration' preserves sound using another script. | +| bot_question | TextField () | Provide the first question that the bot will ask when the state is triggered. | +| completion_criteria | TextField () | Define the criteria required to move from this state to the next state. | +| context | TextField () | Provide the main prompt or description of the state, explaining its purpose. | +| tool_context | TextField () | | +| preprocess_type | CharField (required, max_length=10, choices) | Choose how this stage should be preprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Preprocess Bot' lets you select a separate bot to handle complex logic. | +| preprocess_prompt | TextField () | Define the skip logic prompt if Preprocess Type is SIMPLE. | +| preprocess_bot | ForeignKey (ForeignKey → CompanyBot) | Select which Bot to use for preprocessing for complex logic. | +| preprocess_output_mode | CharField (required, max_length=10, choices) | Define how to use the preprocess output: 'Skip' means use output to decide if stage should be skipped; 'Enrich' means use output in this stage's prompt; 'Custom' means run custom logic on the output. | +| postprocess_type | CharField (required, max_length=10, choices) | Choose how this stage should be postprocessed: 'Simple Prompt' lets you define a direct prompt, 'Use Postprocess Bot' lets you select a separate bot to handle complex logic. | +| postprocess_prompt | TextField () | Define the postprocess prompt if Postprocess Type is SIMPLE. | +| postprocess_bot | ForeignKey (ForeignKey → CompanyBot) | Select which Bot to use for postprocessing for complex logic. | +| postprocess_output_mode | CharField (required, max_length=10, choices) | Define how to use the postprocess output. | +| skip_to_step | IntegerField () | If set, the flow will skip directly to this step number when skip conditions are met. | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_postprocess_output_mode_display()` +- `get_postprocess_type_display()` +- `get_preprocess_output_mode_display()` +- `get_preprocess_type_display()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_text_conversion_type_display()` +- `get_type_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 8. KeyValue + +`chatbot/models/media_models.py` + +### Purpose + +Stores structured key-value metadata associated with a Media document. + Used for tagging or storing extracted attributes. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| media | ForeignKey (required, ForeignKey → Media) | | +| key | CharField (required, max_length=1000) | | +| value | TextField () | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 9. Media + +`chatbot/models/media_models.py` + +### Purpose + +Represents knowledge/media files linked to a company bot. + Handles storage, preview generation, vector indexing, and similarity search. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (required, max_length=1000) | | +| organization | ForeignKey (ForeignKey → Company) | | +| url | URLField (max_length=1000) | | +| priority | CharField (required, max_length=50, choices) | | +| media_type | CharField (required, max_length=100, choices) | | +| company_bot | ForeignKey (required, ForeignKey → CompanyBot) | | +| file | FileField (required, max_length=1000) | | +| markdown_file | FileField (max_length=1000) | | +| description | TextField () | | +| extracted_text | TextField () | | +| external_file_id | CharField (max_length=300) | External provider file identifier used for vector indexing (e.g. OpenAI Files API file_id) | +| parent | ForeignKey (ForeignKey → Media) | | +| display_mode | CharField (required, max_length=20, choices) | | +| view_count | PositiveBigIntegerField (required) | | +| download_count | PositiveBigIntegerField (required) | | +| thumbnail | ImageField (max_length=1000) | Auto-generated preview thumbnail | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| tags | ManyToManyField (required, ManyToMany → Tag) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `find_trigram_similar()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_display_mode_display()` +- `get_file_upload_path()` +- `get_media_type_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_priority_display()` +- `get_s3_url()` +- `get_thumbnail_s3_url()` +- `get_thumbnail_upload_path()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 10. MediaImage + +`chatbot/models/media_models.py` + +### Purpose + +Stores images extracted or associated with a Media document. + Maintains ordering and metadata like page number and dimensions. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (required, max_length=1000) | | +| file | FileField (max_length=1000) | | +| media | ForeignKey (required, ForeignKey → Media) | | +| page | IntegerField () | | +| index | IntegerField (required) | | +| width | IntegerField () | | +| height | IntegerField () | | +| media_type | CharField (max_length=100, choices) | | +| base64_str | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_file_upload_path()` +- `get_media_type_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 11. MediaTemplate + +`chatbot/models/media_models.py` + +### Purpose + +Defines reusable templates for processing or rendering Media content. + Supports different template types and PDF handling strategies. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (unique=True, max_length=100) | | +| template_content | TextField () | | +| template_type | CharField (max_length=100, choices) | | +| pdf_strategy | CharField (max_length=100, choices) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_pdf_strategy_display()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_template_type_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 12. MediaVector + +`chatbot/models/media_models.py` + +### Purpose + +Stores vector database reference IDs for a Media document. + Used for semantic search and embedding-based retrieval. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| media | ForeignKey (required, ForeignKey → Media) | | +| vector_id | CharField (max_length=1000) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 13. Profile + +`chatbot/models/profile_models.py` + +### Purpose + +Represents a user profile associated with a company. + Stores personal details, authentication data, and metadata for chatbot interactions. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| first_name | CharField (max_length=100) | | +| userid | CharField (max_length=200) | | +| last_name | CharField (max_length=100) | | +| email | EmailField (required, max_length=1000) | | +| phone | CharField (max_length=20) | | +| alternate_phone | CharField (max_length=20) | | +| country | CharField (max_length=100) | | +| status | CharField (required, max_length=20, choices) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| company | ForeignKey (required, ForeignKey → Company) | | +| password | CharField (max_length=1000) | | +| profile_type | CharField (required, max_length=20, choices) | | +| profile_code | CharField (max_length=100) | | +| location | CharField (max_length=1000) | | +| caste | CharField (max_length=1000) | | +| gender | CharField (max_length=1000, choices) | | +| designation | TextField () | | +| org_associated | CharField (max_length=1000) | | +| product_interested | CharField (max_length=1000) | | +| company_spoc | CharField (max_length=1000) | | +| other_params | JSONField () | | +| source | CharField (max_length=1000) | | +| preferred_route | CharField (max_length=1000) | | +| latest_flow_used | CharField (max_length=500, choices) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_file_upload_path()` +- `get_gender_display()` +- `get_latest_flow_used_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_profile_type_display()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 14. ProfileAddress + +`chatbot/models/geo_models.py` + +### Purpose + +Stores address and geolocation details associated with a user profile. + Includes full address fields along with optional latitude and longitude. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| profile | ForeignKey (required, ForeignKey → Profile) | | +| address_line_1 | CharField (max_length=1000) | | +| address_line_2 | CharField (max_length=1000) | | +| block | CharField (max_length=1000) | | +| city | CharField (max_length=1000) | | +| district | CharField (max_length=1000) | | +| state | CharField (max_length=1000) | | +| country | CharField (max_length=1000) | | +| pincode | CharField (max_length=10) | | +| latitude | DecimalField () | | +| longitude | DecimalField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 15. ProfileMedia + +`chatbot/models/media_models.py` + +### Purpose + +Stores media files uploaded by a user profile. + Encodes files to base64 and provides public S3 access. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| profile | ForeignKey (required, ForeignKey → Profile) | | +| file | FileField (required, max_length=1000) | | +| base64_str | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_file_upload_path()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_public_url()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 16. Story + +`chatbot/models/story_models.py` + +### Purpose + +Represents a story created by a user or AI within a chat session. + Stores content, metadata, language, status, and translation support. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| title | CharField (required, max_length=1000) | | +| author | ForeignKey (ForeignKey → Profile) | | +| content | TextField () | | +| blurb | TextField () | | +| tweet | TextField () | | +| session | CharField (unique=True, required, max_length=255) | | +| objective | TextField () | | +| action_steps | TextField () | | +| impact | TextField () | | +| micro_improvement | TextField () | | +| location | CharField (max_length=1000) | | +| district | CharField (max_length=1000) | | +| state | CharField (max_length=1000) | | +| block | CharField (max_length=1000) | | +| formatted_content | TextField () | | +| language | CharField (required, max_length=1000, choices) | | +| source | CharField (required, max_length=1000, choices) | | +| story_code | CharField (max_length=100) | | +| stage | CharField (required, max_length=100, choices) | | +| summary | TextField () | | +| other_params | JSONField () | | +| client_created_at | DateTimeField () | | +| client_updated_at | DateTimeField () | | +| validation_logs | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_available_languages()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_language_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_source_display()` +- `get_stage_display()` +- `get_translation()` +- `get_translation_languages()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 17. StoryMedia + +`chatbot/models/story_models.py` + +### Purpose + +Stores media files associated with a story. + Handles file uploads, format conversion, and base64 encoding. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (required, max_length=1000) | | +| file | FileField (max_length=1000) | | +| story | ForeignKey (required, ForeignKey → Story) | | +| include_in_story | BooleanField (required) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| base64_str | TextField () | | +| source_path | TextField () | | +| media_type | CharField (max_length=100, choices) | | +| file_url | CharField (max_length=2000) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_file_upload_path()` +- `get_media_type_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_public_url()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 18. StoryTag + +`chatbot/models/story_models.py` + +### Purpose + +Maps tags to stories with optional primary tag designation. + Ensures a story cannot have duplicate tags. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| story | ForeignKey (required, ForeignKey → Story) | | +| tag | ForeignKey (required, ForeignKey → Tag) | | +| is_primary | BooleanField (required) | | +| created_by | ForeignKey (ForeignKey → Profile) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 19. StoryTranslation + +`chatbot/models/story_models.py` + +### Purpose + +Stores translated versions of a story in different languages. + Maintains localized content while linking to the original story. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| story | ForeignKey (required, ForeignKey → Story) | | +| language | CharField (required, max_length=10, choices) | | +| title | CharField (required, max_length=1000) | | +| content | TextField () | | +| blurb | TextField () | | +| tweet | TextField () | | +| objective | TextField () | | +| action_steps | TextField () | | +| impact | TextField () | | +| micro_improvement | TextField () | | +| formatted_content | TextField () | | +| location | CharField (max_length=1000) | | +| district | CharField (max_length=1000) | | +| state | CharField (max_length=1000) | | +| block | CharField (max_length=1000) | | +| other_params | JSONField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_language_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 20. StoryVernacular + +`chatbot/models/story_vernacular_model.py` + +### Purpose + +Stores language-specific translations for story-related bot content. + Links a company bot to translated JSON text for a given language. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| company_bot | ForeignKey (ForeignKey → CompanyBot) | | +| translation_json | JSONField () | JSON object containing translated text in the specified language. | +| language | CharField (required, max_length=250) | Language code, Example for English use en. | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 21. Tag + +`chatbot/models/story_models.py` + +### Purpose + +Represents a reusable tag used to categorize stories. + Can be company-specific and linked to a creator profile. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (unique=True, required, max_length=1000) | | +| status | CharField (required, max_length=100, choices) | | +| company | ForeignKey (ForeignKey → Company) | | +| source_type | CharField (max_length=50, choices) | | +| description | TextField () | | +| created_by | ForeignKey (ForeignKey → Profile) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_source_type_display()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 22. Theme + +`chatbot/models/theme_models.py` + +### Purpose + +Stores theme configurations associated with a company bot. + Supports custom story themes or inheritance from a master theme. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| bot | ForeignKey (required, ForeignKey → CompanyBot) | Select the bot this theme belongs to. | +| themes | JSONField (required) | Store a list of themes associated with this bot. | +| theme_type | CharField (required, max_length=10, choices) | Indicates if this theme is custom or uses a master theme. | +| master_theme | ForeignKey (ForeignKey → Theme) | If using a master theme, select the theme to inherit from. | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_theme_type_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 23. Voice + +`chatbot/models/company_models.py` + +### Purpose + +Defines a text-to-speech voice configuration for a company bot. + Stores provider details, language, gender, and playback settings. + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| company_bot | ForeignKey (ForeignKey → CompanyBot) | | +| type | CharField (max_length=300, choices) | | +| provider | CharField (max_length=300, choices) | | +| name | CharField (max_length=100) | | +| sample_link | URLField (max_length=200) | | +| language | CharField (max_length=100) | | +| provider_code | CharField (max_length=100) | | +| gender | CharField (required, max_length=100, choices) | | +| voice_speed | FloatField () | | +| other_params | JSONField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_gender_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_provider_display()` +- `get_type_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- diff --git a/docs/apps/chatbot/overview.md b/docs/apps/chatbot/overview.md new file mode 100644 index 0000000..e5bcdfb --- /dev/null +++ b/docs/apps/chatbot/overview.md @@ -0,0 +1,43 @@ +# Chatbot Application + +## Introduction + +The Chatbot application is a key component of the Shikshalokam platform, designed to provide intelligent conversational capabilities to users. It supports various bot strategies to interact with users in different contexts, managing sessions, messages, and user profiles. + +## Purpose and Features + +- Handles multi-strategy chatbot interactions including guided, one-shot, and guest discussion bots. +- Supports real-time communication via websocket consumers. +- Integrates asynchronous message handling using Celery tasks for efficient background processing. +- Comprehensive authentication with JWT and token blacklisting. + +## High-Level Architecture + +The Chatbot app is organized into the following major components: + +- **Authentication**: Manages user authentication and token validation. +- **Services**: Core business logic including chat orchestration, message preparation, and prompt building. +- **Strategies**: Defines behavior for different bot types. +- **Consumers**: WebSocket consumers for real-time interactions. +- **Celery Tasks**: Background processing tasks related to messaging. +- **Utilities**: Helper functions and utilities supporting various operations. +- **Management**: Django management commands (if any). +- **URLs and Routing**: Endpoint definitions and ASGI routing for websocket. +- **Templates**: Frontend components associated with the chatbot. + +## Folder Structure + +```plain +chatbot/ +├── auth.py # Authentication logic +├── services/ # Core chatbot services +│ ├── core/ # Core service implementations +│ └── strategies/ # Bot behavior strategies +├── consumers/ # Websocket consumers +├── celery_tasks/ # Asynchronous task processing +├── utils/ # Utility functions +├── management/ # Management commands +├── urls.py # Django URL routing +├── routing.py # ASGI routing for websockets +└── templates/ # Frontend templates +``` diff --git a/docs/apps/chatbot/views.md b/docs/apps/chatbot/views.md new file mode 100644 index 0000000..a3d0021 --- /dev/null +++ b/docs/apps/chatbot/views.md @@ -0,0 +1,646 @@ +# View Layer + +The View layer exposes HTTP endpoints and acts as the execution boundary between client requests and backend workflows. + +Responsibilities of this layer: + +- Parse and validate incoming requests +- Handle authentication (JWT where applicable) +- Resolve contextual entities (User, Company, CompanyBot) +- Trigger business workflows +- Initiate asynchronous tasks when required +- Return structured JSON responses + +Views coordinate execution but do not contain heavy domain logic. + +--- + +## 1. Chat APIs + +### `chatbot/views/chat_view.py` + +#### Purpose +Implements conversational session lifecycle and message persistence. + +#### Responsibilities + +- Create chat sessions +- Persist user messages +- Persist bot messages +- Associate chat sessions with CompanyBot +- Resolve authenticated user context +- Maintain chronological conversation ordering +- Structure response payload for frontend +- Ensure conversation continuity across requests + +This is the primary entry point for conversational workflows. + +--- + +## 2. Authentication, Profile & Session APIs + +### `chatbot/views/api_views.py` + +#### Purpose + +Handles session generation, profile synchronization, authentication, and token management. + +This module initializes and maintains authenticated user context before domain workflows begin. + +--- + +#### Responsibilities + +##### 1. Session Initialization + +- Generate Django session ID using `SessionStore` +- Return session key to client +- Establish session-based tracking + +--- + +##### 2. Profile Creation & Synchronization (`post_profile`) + +- Validate required fields (email + company/subdomain) +- Resolve Company using slug +- Create or update Profile record +- Handle phone-based fallback lookup +- Serialize and persist profile data +- Perform first-name transliteration using AI4Bharat API (when preferred language is provided) +- Support demo / development company slugs + +Ensures idempotent profile initialization aligned with company context. + +--- + +##### 3. Login (`login`) + +- Validate email and password +- Verify hashed password using `check_password` +- Fetch associated ProfileAddress +- Issue JWT access token via `RefreshToken` +- Store session authentication state +- Return authenticated profile metadata + +--- + +##### 4. Logout (`logout`) + +- Extract token from Authorization header +- Blacklist JWT token via `BlacklistedToken` +- Clear Django session +- Remove session cookie + +--- + +#### Architectural Role + +This module: + +- Establishes authenticated user identity +- Resolves company context +- Issues JWT credentials +- Synchronizes profile state +- Manages session lifecycle + +It is the authentication boundary layer for the application. + +--- + +## 3. Recommendation APIs + +### `chatbot/views/recommendation.py` + +#### Purpose +Provides structured domain-level recommendations (e.g., project recommendations). + +#### Responsibilities + +- Accept contextual filters or identifiers +- Execute recommendation logic +- Rank or filter recommendation results +- Format structured response output +- Return deterministic response schema + +This endpoint is independent from conversational workflows. + +--- + +## 4. Translation, Voice & Transliteration APIs + +### `chatbot/views/bhashini_views.py` + +#### Purpose + +Provides multilingual processing and voice transformation endpoints. + +This module dynamically selects language providers based on `CompanyBot` configuration and `VoiceType`. + +--- + +#### Responsibilities + +##### 1. Text-to-Speech (`text_speech_view`) + +- Validate required route +- Resolve `CompanyBot` using route +- Select configured `Voice` provider (TextToSpeech) +- Generate audio from text via `text_speech_provider` +- Return encoded audio content + +--- + +##### 2. Speech-to-Text (`speech_text`) + +- Fetch audio from S3 URL +- Convert audio to WAV base64 format +- Resolve `CompanyBot` and fallback to `/common_bot` if needed +- Select SpeechToText voice provider +- Generate transcript via `speech_text_provider` +- Return transcription output + +--- + +##### 3. Text Translation (`text_translation_view`) + +- Resolve `CompanyBot` using route +- Select TextToText voice provider +- Translate message via `text_translate_provider` +- Return translated transcript + +--- + +##### 4. Transliteration (`text_transliterate_view`) + +- Resolve `CompanyBot` using route +- Select Transliterate voice provider +- Optionally detect source language using AI4Bharat API +- Perform script-level transliteration via `transliterate_text` +- Return transliterated output + +--- + +#### Architectural Role + +This module: + +- Acts as multilingual abstraction layer +- Dynamically selects providers per bot configuration +- Integrates external language APIs +- Supports STT, TTS, Translation, and Transliteration +- Maintains consistent JSON response structure + +It centralizes all language transformation workflows behind route-based configuration. + +--- + +## 5. Media & Knowledge Ingestion APIs + +### `chatbot/views/Media/document_upload_view.py` + +#### Responsibilities + +- Accept document upload requests +- Validate file inputs +- Create Media model entries +- Associate media with Company context +- Persist metadata fields +- Store initial structured data + +--- + +### `chatbot/views/Media/upload_views.py` + +#### Responsibilities + +- Handle media upload workflows +- Normalize request payload +- Save structured media-related information +- Prepare media records for downstream processing + +--- + +### `chatbot/views/Media/extract_views.py` + +#### Responsibilities + +- Trigger AI extraction workflows +- Initiate asynchronous Celery tasks +- Pass relevant media identifiers +- Manage extraction initiation state + +--- + +### `chatbot/views/Media/save_views.py` + +#### Purpose + +Handles structured save operations related to Media entities. + +#### Responsibilities + +- Accept media-related update requests +- Persist structured metadata changes +- Update existing Media model fields +- Ensure data validation before persistence +- Return updated media state + +This view complements upload and extraction workflows by handling structured persistence updates after initial creation. + +--- + +### `chatbot/views/Media/status_views.py` + +#### Responsibilities + +- Accept Celery task IDs +- Query task readiness using AsyncResult +- Return structured task status +- Handle success and failure states +- Support frontend polling for async workflows + +--- + +### `chatbot/views/Media/media_tracking_views.py` + +#### Responsibilities + +- Track ingestion state of media records +- Return structured tracking information +- Expose status metadata for frontend monitoring + +--- + +### `chatbot/views/Media/media_views.py` + +#### Responsibilities + +- Retrieve media objects +- Return structured media details +- Support CRUD-like media interactions + +--- + +### `chatbot/views/Media/media_api_views.py` + +#### Responsibilities + +- Implement PostgreSQL Full-Text Search (SearchVector) +- Apply SearchRank ordering +- Enable tag-based filtering +- Enable key-value metadata filtering +- Support query parameter-based search +- Implement pagination or limit-based slicing + +Provides structured and ranked retrieval over ingested knowledge assets. + +--- + +## 6. Story Management APIs + +### `chatbot/views/story_views.py` + +#### Purpose + +Manages the full lifecycle of Story entities, including: + +- Story creation from chat sessions +- Multilingual translation handling +- Story updates with synchronization +- Media attachment +- Story recreation +- Automatic PDF regeneration + +--- + +#### Core Responsibilities + +##### 1. Story Creation (`end_story`) + +- Create structured Story from completed chat session +- Use `create_story_object` utility +- Support flow-based creation +- Return generated story ID and content + +##### 2. Story CRUD (DRF-Based) + +- List and create stories (`StoryListCreateView`) +- Retrieve, update, delete stories (`StoryRetrieveUpdateDestroyView`) +- Filter by session and author + +##### 3. Multilingual Translation Handling + +- Detect language using `LanguageDetectionMixin` +- If language ≠ English: + - Get or create translation record + - Update translated fields + - Sync translated content back to main story +- Maintain translation integrity across updates + +##### 4. Automatic PDF Regeneration + +When story is updated: + +- Trigger `update_story_pdf` +- Skip PDF update for Reflection flow +- Ensure story artifacts stay synchronized after edits + +##### 5. Story Media Management + +- Attach media to stories (`StoryMediaListCreateView`) +- Update/delete story media +- Trigger PDF regeneration on media changes +- Maintain story-media associations + +##### 6. Profile Media Management + +- CRUD operations for profile-level media +- Filter by profile + +##### 7. Story Recreation (`story_recreate_view`) + +- Reconstruct Story from profile + session +- Use `re_create_story_object` +- Useful for regenerating lost or inconsistent story content + +##### 8. Story Retrieval by Session + +- Fetch all stories associated with a session +- Return full serialized story representation + +--- + +#### Architectural Role + +This module: + +- Bridges chat sessions and persistent Story records +- Handles multilingual story synchronization +- Manages story-level media attachments +- Keeps story PDFs consistent with content updates +- Provides deterministic CRUD APIs via DRF + +It acts as the domain controller for narrative content lifecycle management. + +--- + +## 7. Profile Management APIs + +### `chatbot/views/profile_views.py` + +#### Responsibilities + +- Create profile records +- Update profile information +- Retrieve profile details +- Associate profile with Company +- Maintain profile integrity constraints + +Separate from authentication/session initialization logic. + +--- + +## 8. Location APIs + +### `chatbot/views/location_views.py` + +#### Responsibilities + +- Fetch location data +- Return structured location responses +- Provide contextual location information + +--- + +## 9. Infrastructure Integration APIs + +### `chatbot/views/aws_views.py` + +#### Responsibilities + +- Generate S3 presigned URLs +- Validate upload-related parameters +- Return signed access credentials + +--- + +### `chatbot/views/kafka_views.py` + +#### Responsibilities + +- Accept structured payloads +- Perform request validation +- Trigger Kafka-related operations +- Return status response + +Documentation reflects only the request-level responsibilities of this view. + +--- + +### `chatbot/views/gotenberg_view.py` + +#### Responsibilities + +- Accept document payload +- Trigger PDF rendering process +- Return rendered PDF response +- Handle response formatting + +--- + +## 10. DRF-Based Generic APIs + +### `chatbot/views/drf_views.py` + +#### Purpose + +Provides Django REST Framework–based generic CRUD endpoints for core models. + +#### Responsibilities + +- Implement ListCreateAPIView and RetrieveUpdateAPIView patterns +- Expose model-level CRUD operations +- Apply serializer-based validation +- Integrate Django Filter backend for query filtering +- Support pagination and query parameter filtering +- Return standardized DRF response formats + +This module centralizes DRF-based CRUD patterns instead of writing custom views for each model. + +--- + +## 11. Mitra Project Creation & Report API + +### `chatbot/views/mitra_views.py` + +#### Purpose + +Handles Mitra project creation along with automated report generation (PDF & Excel). + +--- + +#### Responsibilities + +- Validate required project inputs +- Optionally create external project via `create_project_utils` (if access token is provided) +- Create internal Mitra project via `create_mitra_project_utils` +- Normalize source data and format project timeline +- Generate structured PDF report +- Generate structured Excel report +- Upload generated files to S3 using `upload_media` +- Attach media references to the created project +- Handle report-generation exceptions gracefully + +--- + +#### Architectural Role + +This endpoint combines: + +- Project persistence +- External project synchronization +- Document generation +- Media storage integration + +It is a domain workflow API focused on project lifecycle and artifact generation, not conversational processing. + +--- + +## 12. Admin & Configuration Views + +These views are accessible through Django Admin and are restricted to authenticated staff users. + +They enable configuration management, bulk operations, and admin-triggered processing workflows that are not exposed to public APIs. + +--- + +### `chatbot/views/admin/bot_admin_views.py` + +#### Purpose + +Implements import and export workflows for `CompanyBot` along with its related configuration models. + +#### Detailed Responsibilities + +- Export a CompanyBot configuration into structured JSON +- Include related inline models during export: + - Voices + - State Machines + - Bot Vernacular +- Generate JSON templates to guide correct import format +- Import CompanyBot configuration from JSON payload +- Reconstruct related inline objects during import +- Detect whether to update an existing bot (route-based matching) or create a new one +- Execute the entire import inside a database transaction to ensure atomicity +- Enforce permission-based restrictions (e.g., superuser/moderator controls) + +#### What This Enables + +- Safe migration of bot configurations between environments +- Backup and restore of complex bot setups +- Replication of conversational configurations across tenants +- Structured configuration management without manual DB manipulation + +This view effectively serializes and reconstructs bot-level conversational configuration. + +--- + +### `chatbot/views/admin/generic_upload_views.py` + +#### Purpose + +Provides a reusable bulk upload engine for arbitrary Django models via CSV. + +#### Detailed Responsibilities + +- Dynamically inspect model fields using Django model metadata +- Generate downloadable CSV templates reflecting model structure +- Parse uploaded CSV files row-by-row +- Perform type conversion for fields (Integer, Boolean, Date, etc.) +- Resolve ForeignKey references using lookup logic +- Resolve ManyToMany relationships +- Validate required fields and field constraints +- Collect row-level validation errors +- Execute bulk inserts/updates within database transactions +- Return structured success/error summaries + +#### What This Enables + +- Admin-level batch data ingestion without writing custom import scripts +- Controlled mass updates for structured models +- Reduced risk of manual data-entry inconsistencies +- Transaction-safe bulk operations with validation feedback + +This acts as a generic data ingestion utility within the admin layer. + +--- + +### `chatbot/views/admin/post_processing_views.py` + +#### Purpose + +Triggers asynchronous post-processing workflows on Story entities. + +These workflows refine, filter, or transform story-related data using background tasks. + +#### Detailed Responsibilities + +- Accept admin-triggered processing requests +- Dynamically determine processing type based on configuration +- Validate required input parameters +- Trigger Celery-based asynchronous post-processing tasks +- Return Celery task ID for tracking +- Provide task status polling endpoint +- Handle success and failure reporting +- Return structured iteration or transformation statistics + +#### Processing Examples + +Depending on configuration, post-processing may include: + +- Unique challenge extraction +- Unique solution extraction +- Deduplication logic +- Content refinement +- Iterative filtering workflows + +#### What This Enables + +- Admin-triggered refinement pipelines +- Controlled execution of AI-based story processing +- Asynchronous transformation without blocking admin interface +- Transparent task monitoring via polling endpoints + +This view provides structured control over background story refinement processes. + +--- + +## Admin Layer Characteristics + +- Restricted to authenticated staff users +- Transaction-safe configuration changes +- Async-aware processing for heavy workflows +- Structured validation and error reporting +- Designed for configuration management and controlled bulk operations + +--- + +## View Layer Execution Characteristics + +### 1. Thin Request Boundary +Views manage request lifecycle and workflow initiation. + +### 2. Async-Aware Architecture +Heavy workflows (extraction, embedding) rely on Celery. + +### 3. Structured Persistence Before Async Execution +Data is persisted before triggering asynchronous workflows. + +### 4. Context Resolution +User, Company, and CompanyBot context are resolved early. + +### 5. Deterministic Response Contracts +All endpoints return predictable JSON schemas. + +### 6. Separation of Runtime & Admin Flows +Runtime APIs and admin workflows are clearly separated. diff --git a/docs/apps/observability/models.md b/docs/apps/observability/models.md new file mode 100644 index 0000000..b44a815 --- /dev/null +++ b/docs/apps/observability/models.md @@ -0,0 +1,224 @@ +# Django Models + +`observability/models/` + +This layer defines the complete database schema for the `observability` application. + +It manages persistence, relationships, constraints, indexing, and domain-level behavior across domain entities and system configuration. + +--- + +## Responsibilities of this Layer + +- Define core domain entities +- Maintain relational integrity using ForeignKeys and constraints +- Enforce validation rules and uniqueness constraints +- Manage state and lifecycle tracking +- Support indexing and optimized querying +- Provide model-level helper methods for business logic +- Use enums for consistent state definitions + +--- + +## 1. BotRunTestCaseMap + +`observability/models/base_models.py` + +### Purpose + +BotRunTestCaseMap(id, bot_run, test_case, metric_name, score, reason, status, response_log, created_at, updated_at) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| bot_run | ForeignKey (required, ForeignKey → CompanyBotTCRun) | | +| test_case | ForeignKey (required, ForeignKey → CompanyBotTestCases) | | +| metric_name | CharField (required, max_length=100, choices) | | +| score | FloatField () | | +| reason | TextField () | | +| status | CharField (max_length=100, choices) | | +| response_log | TextField () | | +| created_at | DateTimeField () | | +| updated_at | DateTimeField () | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_metric_name_display()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 2. CompanyBotTCRun + +`observability/models/base_models.py` + +### Purpose + +CompanyBotTCRun(id, created_at, updated_at, company_bot, llm_model, provider, status, metrics_result) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| company_bot | ForeignKey (required, ForeignKey → CompanyBot) | | +| llm_model | CharField (required, max_length=100, choices) | | +| provider | CharField (required, max_length=100, choices) | | +| status | CharField (required, max_length=100, choices) | | +| metrics_result | TextField () | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_llm_model_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_provider_display()` +- `get_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 3. CompanyBotTestCases + +`observability/models/base_models.py` + +### Purpose + +CompanyBotTestCases(id, about, company_bot, testcase_input, expected_output, chat_session, message, retrieval_context, input_format, json_output_schema, created_at, updated_at) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| about | TextField () | Optional description of the test case. For informational purposes only; it does not affect the test output. | +| company_bot | ForeignKey (ForeignKey → CompanyBot) | | +| testcase_input | TextField () | | +| expected_output | TextField (required) | | +| chat_session | ForeignKey (ForeignKey → ChatSession) | | +| message | TextField () | | +| retrieval_context | TextField () | | +| input_format | CharField (required, max_length=100, choices) | | +| json_output_schema | TextField () | | +| created_at | DateTimeField () | | +| updated_at | DateTimeField () | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_input_format_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 4. TCBotRunMetrics + +`observability/models/base_models.py` + +### Purpose + +TCBotRunMetrics(id, bot_tc_run, metric_name, assessment_questions, metric_threshold_value, metric_score, reason) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| bot_tc_run | ForeignKey (required, ForeignKey → CompanyBotTestCases) | | +| metric_name | CharField (required, max_length=100, choices) | | +| assessment_questions | TextField () | | +| metric_threshold_value | FloatField (required) | | +| metric_score | FloatField () | | +| reason | TextField () | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_metric_name_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- diff --git a/docs/apps/observability/observability_admin.md b/docs/apps/observability/observability_admin.md new file mode 100644 index 0000000..41a5d7c --- /dev/null +++ b/docs/apps/observability/observability_admin.md @@ -0,0 +1,48 @@ +# Observability Admin + +## Overview + +This section documents the Django Admin customizations for the Observability app. It provides developer-level understanding of the administrative views that facilitate managing bot runs, test case mappings, and their statuses. + +## Admin Classes + +### CompanyBotRunTestCaseMapAdmin + +Located in `admin/bot_run_test_case_map_admin.py`, this class manages the `BotRunTestCaseMap` model admin interface. + +- **List Display:** Shows columns for bot run, metric name, test case, status, and creation timestamp. +- **Raw ID Fields:** Uses raw ID lookup for foreign keys to bot_run and test_case to optimize performance. +- **List Filters:** Enables filtering by status, metric name, related bot run and test case references, and includes a custom date filter (`CustomAdvanceDateFilter`). +- **Search Fields:** Allows searching by metric name, status, bot run ID, test case description, and response log. +- **Date Hierarchy:** Enables drill-down navigation by created_at field. +- **Ordering:** Default ordering is newest entries first by descending `created_at`. + +These configurations improve admin usability for managing large datasets related to bot run tests and their evaluation metrics. + +--- + +## CompanyBotTCRunAdmin + +Located in `admin/company_bot_tc_run_admin.py`, this admin class manages the `CompanyBotTCRun` model. + +- **Read-only Fields:** `status` and `metrics_result` fields are read-only to prevent manual editing. +- **Raw ID Fields:** Uses raw ID widget for the foreign key `company_bot` for efficient selection. +- **List Display:** Shows columns for company bot, run status, and creation timestamp. +- **List Filters:** Enables filtering by run status, related company bot, and creation date with a custom date filter. +- **Search Fields:** Allows searching by company bot name and status. +- **Date Hierarchy:** Provides date drill-down navigation based on `created_at`. +- **Ordering:** Displays newest runs first by ordering on `created_at` descending. + +## CompanyBotTestCasesAdmin + +Located in `admin/company_bot_test_cases_admin.py`, this admin class manages `CompanyBotTestCases` model. + +- **List Display:** Displays columns for company bot, test case description, and creation date. +- **Raw ID Fields:** Uses raw ID for efficient selection of related company bot. +- **List Filters:** Filters by company bot and creation date using custom date filter. +- **Search Fields:** Enables searching by test case description, related company bot name, test case input, and expected output. +- **Date Hierarchy:** Enables drill down by creation date. +- **Ordering:** Orders by newest test cases first. +- **Inline Administration:** Displays `TCBotRunMetrics` inline only when editing an existing test case, allowing direct management of related metrics. + +--- diff --git a/docs/apps/observability/observability_celery_tasks.md b/docs/apps/observability/observability_celery_tasks.md new file mode 100644 index 0000000..31a027f --- /dev/null +++ b/docs/apps/observability/observability_celery_tasks.md @@ -0,0 +1,40 @@ +# Observability Celery Tasks + +## Overview + +The Observability app uses asynchronous Celery tasks to manage the execution and evaluation of chat bot test cases. + +These tasks facilitate running LLM (Large Language Model) test cases, compute evaluation metrics for each test run, and persist the results for later analysis. + +## Key Components + +### execute_test_case function + +This function is responsible for executing a single test case against a specified LLM model. + +- Inputs include the test case object, LLM model name, various providers, system prompts, test run IDs, temperature, and provider keys. +- Dynamically imports required models to avoid circular dependencies. +- Uses the DeepEvalBaseLLM wrapper to load evaluation models. +- Defines a set of metrics to evaluate test output, including relevancy, faithfulness, precision, recall, bias, toxicity, summarization, prompt alignment, hallucination, etc. +- Executes the LLM prompt and captures the actual output and errors. +- Runs metric evaluations and persists results and status in the `BotRunTestCaseMap` model. + +### run Celery Task + +This shared_task function orchestrates the complete test run for a given bot: + +- Retrieves the company bot and active test run details. +- Fetches all test cases associated with the bot. +- Iterates through each test case, calling `execute_test_case` for evaluation. +- Aggregates metric scores and updates run status accordingly. +- Handles errors gracefully and updates test run status to FAILED when necessary. + +## Additional Notes + +- This module leverages the `deepeval` package for sophisticated evaluation metrics. +- Integration with chatbot models and utility functions such as environment parsers and chat message formatters is essential. +- Results are stored with detailed logs, enabling detailed post-run analyses. + +--- + +These celery tasks enable automated, scalable, and in-depth testing of chatbot behaviors, helping developers maintain quality assurance efficiently. diff --git a/docs/apps/observability/observability_urls.md b/docs/apps/observability/observability_urls.md new file mode 100644 index 0000000..e0bf9b7 --- /dev/null +++ b/docs/apps/observability/observability_urls.md @@ -0,0 +1,17 @@ +# Observability URLs + +## Overview + +This module defines the URL routing patterns for the Observability app. + +## URL Patterns + +- `test_bot_prompt/` + - Routed to `test_prompt_view` in `views.py`. + - Used to create a new test run for a specified company bot with primary key `pk`. + +- Includes debug toolbar URLs via `debug_toolbar_urls()` for debugging and profiling during development. + +--- + +This routing setup allows integration of observability testing endpoints with developer debugging tools. diff --git a/docs/apps/observability/observability_utils.md b/docs/apps/observability/observability_utils.md new file mode 100644 index 0000000..e11c1ee --- /dev/null +++ b/docs/apps/observability/observability_utils.md @@ -0,0 +1,28 @@ +# Observability Utilities + +## Overview + +This module includes utility functions that support the operations of the Observability app by providing helper methods for chat processing and deep evaluation model integration. + +## preparechats.py + +### get_chat_dict function + +- Converts a chat session's messages into a dictionary format compatible with LLM input. +- Takes `chat_session_id` and an optional `exclude_end_ai_message` flag to omit the final AI message. +- Extracts messages ordered by creation time, differentiates by sender (user or assistant). +- Returns a list of dictionaries with roles (`user` or `assistant`) and message content. + +## deepeval.py + +### DeepEvalBaseLLM class + +- Wraps the integration with the LiteLLM powered DeepEval base model. +- Supports synchronous (`generate`) and asynchronous (`a_generate`) message completions. +- Handles exceptions and returns model responses conforming to pydantic BaseModel expectations. +- Provides method to get a descriptive model name. +- Utilizes the `instructor` library for interfacing with the LiteLLM completion API. + +--- + +These utilities are key enablers for preparing chat input for evaluation and executing those evaluation queries against specialized LLMs within the Observability app's testing framework. diff --git a/docs/apps/observability/observability_views.md b/docs/apps/observability/observability_views.md new file mode 100644 index 0000000..e55695d --- /dev/null +++ b/docs/apps/observability/observability_views.md @@ -0,0 +1,23 @@ +# Observability Views + +## Overview + +This module contains REST API views exposed by the Observability app for developer interactions and testing bots. + +## test_prompt_view + +- Method: GET +- URL Parameter: `pk` (company bot primary key) + +### Functionality + +- Creates a new test run (`CompanyBotTCRun`) for the specified `company_bot` identified by the primary key. +- Saves the new test run record. +- Returns status `ok` if successful. +- Handles exceptions and returns error status and message on failure. + +### Usage + +This API endpoint can be invoked to trigger a new test run associated with a company bot. + +--- diff --git a/docs/apps/observability/overview.md b/docs/apps/observability/overview.md new file mode 100644 index 0000000..136acd6 --- /dev/null +++ b/docs/apps/observability/overview.md @@ -0,0 +1,44 @@ +# Observability App Overview + +## Introduction + +The Observability app in Shikshalokam Backend is designed to provide comprehensive monitoring and evaluation of bot runs, test cases, and their metrics. It plays a critical role in ensuring the quality and reliability of chatbot interactions by automating the testing of LLM-based chatbots and capturing detailed run-time metrics. + +## Purpose + +- To track and evaluate chatbot test cases using automated, programmable metrics. +- To run asynchronous celery tasks for executing LLM test cases. +- To provide developer tools for managing bot runs, mappings to test cases, and metric tracking. +- To integrate with DeepEval for fine-grained metric evaluations like relevancy, toxicity, hallucination, and more. + +## Key Components + +### Admin +Provides Django admin interfaces for managing bot runs, test case mappings, and their statuses. + +### Celery Tasks +Includes asynchronous tasks that execute test cases against LLMs, calculate metric scores, and store results. + +### Utils +Utility functions for preparing chat message formats and scaffolding deep evaluation clients. + +### Views +Contains REST API views for triggering test prompts and related developer-facing endpoints. + +### URLs +Defines URL routing for observability endpoints, including debug toolbar integration. + +### Models +Handles database models for test cases, runs, metrics, and mappings (docs in separate `models.md`). + +### Tests +Placeholder and initial test cases for the observability app, intended to grow with feature development. + +## Observability Workflow Summary +1. Developer configures test cases and metrics. +2. A bot run triggers async celery tasks to evaluate test cases. +3. Each test case is executed with the specified LLM and metrics are calculated. +4. Results are logged, saved, and aggregated. +5. Developers can view and analyze test run results via Django admin and API. + +--- diff --git a/docs/apps/shikshalokam/admin.md b/docs/apps/shikshalokam/admin.md new file mode 100644 index 0000000..d29b5a6 --- /dev/null +++ b/docs/apps/shikshalokam/admin.md @@ -0,0 +1,15 @@ +- `project_admin.py`: Admin configuration and customizations to manage projects. +- `project_vernacular_admin.py`: Admin setup for project vernacular. +- `wishlist_admin.py`: Admin interface configuration for wishlists. + +## Responsibilities + +- Provides efficient list views, filters, and forms for managing Shikshalokam data. +- Supports inline editing, bulk import/export, and permissions management. +- Ensures data integrity with validation and transaction safety during admin operations. + +## Role in Application + +This module is essential for administrators to perform controlled management of the Shikshalokam data entities and configurations with a user-friendly interface. + +It parallels the chatbot admin component to maintain consistency in management across the platform. \ No newline at end of file diff --git a/docs/apps/shikshalokam/models.md b/docs/apps/shikshalokam/models.md new file mode 100644 index 0000000..e936f97 --- /dev/null +++ b/docs/apps/shikshalokam/models.md @@ -0,0 +1,466 @@ +# Django Models + +`shikshalokam/models/` + +This layer defines the complete database schema for the `shikshalokam` application. + +It manages persistence, relationships, constraints, indexing, and domain-level behavior across domain entities and system configuration. + +--- + +## Responsibilities of this Layer + +- Define core domain entities +- Maintain relational integrity using ForeignKeys and constraints +- Enforce validation rules and uniqueness constraints +- Manage state and lifecycle tracking +- Support indexing and optimized querying +- Provide model-level helper methods for business logic +- Use enums for consistent state definitions + +--- + +## 1. Category + +`shikshalokam/models/template_models.py` + +### Purpose + +Category(id, name, category_id, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| name | CharField (max_length=1000) | | +| category_id | CharField (max_length=255) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 2. Evidence + +`shikshalokam/models/project_models.py` + +### Purpose + +Evidence(id, task, project, remark, evidence_link, type, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| task | ForeignKey (ForeignKey → Task) | | +| project | ForeignKey (ForeignKey → Project) | | +| remark | CharField (max_length=1000) | | +| evidence_link | CharField (max_length=2000) | | +| type | CharField (max_length=250) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 3. LearningResources + +`shikshalokam/models/project_models.py` + +### Purpose + +LearningResources(id, project, name, link, resource_id, app, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| project | ForeignKey (ForeignKey → Project) | | +| name | CharField (max_length=1000) | | +| link | CharField (max_length=2000) | | +| resource_id | CharField (max_length=500) | | +| app | CharField (max_length=500) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 4. Project + +`shikshalokam/models/project_models.py` + +### Purpose + +Project(id, story, project_template, author, categories, description, title, expected_title, actual_title, problem_statement, expected_problem_statement, actual_problem_statement, template_id, project_id, program_id, program_name, recommended_for, keywords, objective, expected_objective, actual_objective, duration, expected_duration, actual_duration, project_status, generated_by, other_params, project_language, project_source, program_source, resource_name, resource_link, project_start_date, project_end_date, solution_download_count, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| story | ForeignKey (ForeignKey → Story) | | +| project_template | ForeignKey (ForeignKey → ProjectTemplate) | | +| author | ForeignKey (ForeignKey → Profile) | | +| categories | TextField () | | +| description | TextField () | | +| title | CharField (max_length=1000) | | +| expected_title | CharField (max_length=1000) | | +| actual_title | CharField (max_length=1000) | | +| problem_statement | TextField () | | +| expected_problem_statement | TextField () | | +| actual_problem_statement | TextField () | | +| template_id | CharField (max_length=500) | | +| project_id | CharField (unique=True, required, max_length=500) | | +| program_id | CharField (max_length=500) | | +| program_name | CharField (max_length=1000) | | +| recommended_for | TextField () | | +| keywords | TextField () | | +| objective | TextField () | | +| expected_objective | TextField () | | +| actual_objective | TextField () | | +| duration | CharField (max_length=1000) | | +| expected_duration | CharField (max_length=1000) | | +| actual_duration | CharField (max_length=1000) | | +| project_status | CharField (max_length=100, choices) | | +| generated_by | CharField (required, max_length=100, choices) | | +| other_params | JSONField () | | +| project_language | CharField (max_length=100) | | +| project_source | TextField () | | +| program_source | TextField () | | +| resource_name | CharField (max_length=1000) | | +| resource_link | CharField (max_length=2000) | | +| project_start_date | DateTimeField () | | +| project_end_date | DateTimeField () | | +| solution_download_count | PositiveBigIntegerField (required) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_generated_by_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `get_project_status_display()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 5. ProjectTemplate + +`shikshalokam/models/template_models.py` + +### Purpose + +ProjectTemplate(id, category, title, template_id, description, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| category | ForeignKey (ForeignKey → Category) | | +| title | CharField (max_length=1000) | | +| template_id | CharField (max_length=255) | | +| description | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 6. ProjectVernacular + +`shikshalokam/models/project_vernacular_model.py` + +### Purpose + +ProjectVernacular(id, project, task, language, details, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| project | ForeignKey (ForeignKey → Project) | | +| task | ForeignKey (ForeignKey → Task) | | +| language | CharField (required, max_length=250) | | +| details | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 7. ProjectWishlist + +`shikshalokam/models/wishlist_model.py` + +### Purpose + +ProjectWishlist(id, author, project, created_at, updated_at) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| author | ForeignKey (required, ForeignKey → Profile) | | +| project | ForeignKey (required, ForeignKey → Project) | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- + +## 8. Task + +`shikshalokam/models/project_models.py` + +### Purpose + +Task(id, project, parent_task_id, task_id, task_name, mandatory_task, observation_name, number_of_submission_observation, other_params, task_status, description, source, created_at, updated_at, created_by) + +### Fields + +| Field | Type & Constraints | Description | +|-------|-------------------|-------------| +| id | BigAutoField (unique=True, required) | | +| project | ForeignKey (required, ForeignKey → Project) | | +| parent_task_id | CharField (max_length=255) | | +| task_id | CharField (max_length=255) | | +| task_name | CharField (max_length=1000) | | +| mandatory_task | CharField (max_length=100, choices) | | +| observation_name | CharField (max_length=255) | | +| number_of_submission_observation | IntegerField () | | +| other_params | JSONField () | | +| task_status | CharField (max_length=100) | | +| description | TextField () | | +| source | TextField () | | +| created_at | DateTimeField (required) | | +| updated_at | DateTimeField (required) | | +| created_by | ForeignKey (ForeignKey → User) | | + +### Methods + +- `DoesNotExist()` +- `MultipleObjectsReturned()` +- `adelete()` +- `arefresh_from_db()` +- `asave()` +- `check()` +- `clean()` +- `clean_fields()` +- `date_error_message()` +- `from_db()` +- `full_clean()` +- `get_constraints()` +- `get_deferred_fields()` +- `get_mandatory_task_display()` +- `get_next_by_created_at()` +- `get_next_by_updated_at()` +- `get_previous_by_created_at()` +- `get_previous_by_updated_at()` +- `prepare_database_save()` +- `refresh_from_db()` +- `save_base()` +- `save_without_historical_record()` +- `serializable_value()` +- `unique_error_message()` +- `validate_constraints()` +- `validate_unique()` + +--- diff --git a/docs/apps/shikshalokam/overview.md b/docs/apps/shikshalokam/overview.md new file mode 100644 index 0000000..6bce2f8 --- /dev/null +++ b/docs/apps/shikshalokam/overview.md @@ -0,0 +1,31 @@ +# Shikshalokam Application + +## Overview + +The Shikshalokam application is an integral part of the overall platform, complementing the chatbot by providing additional domain-specific business functionalities, processing workflows, and integrations. + +## Folder Structure + +The application is organized into the following key components: + +- `admin/`: Django admin customizations for Shikshalokam models. +- `apps.py`: App configuration. +- `migrations/`: Database schema changes. +- `models/`: Data models. +- `resource.py`: Core resource definitions and utilities. +- `scripts/`: Utility and maintenance scripts. +- `serializer/`: Serialization logic for API interactions. +- `tests.py`: Test cases. +- `urls.py`: URL routing for the app. +- `utils/`: Helper functions and utilities supporting various operations. +- `views/`: Web and API view implementations. + +## Relationship to Chatbot + +The Shikshalokam app structure has similarities to the chatbot application, primarily consisting of modular components like utils, serializers, admin, models, and views. + +Future maintenance could further modularize these components into subfolders (e.g., separating services, consumers, tasks) similar to the chatbot app, enhancing clarity and maintainability. + +## Purpose + +This app manages core business logic and integrations distinct from chatbot conversational logic, focusing on broader platform features and domain data. diff --git a/docs/apps/shikshalokam/urls.md b/docs/apps/shikshalokam/urls.md new file mode 100644 index 0000000..cba2990 --- /dev/null +++ b/docs/apps/shikshalokam/urls.md @@ -0,0 +1,9 @@ +## Example Endpoints + +- `/create-story/`: Initiates story creation from project data. +- `/project/`: CRUD operations for projects. +- `/start-project/`: Duplicate project functionality. +- `/ingest-data/`: Data ingestion endpoint. +- `/wishlist-project/`: User project wishlist management. +- `/paraphrase/`: Paraphrase text content API. +- `/generate-objective/` and related endpoints for managing objectives and actions. diff --git a/docs/apps/shikshalokam/utils.md b/docs/apps/shikshalokam/utils.md new file mode 100644 index 0000000..99e9591 --- /dev/null +++ b/docs/apps/shikshalokam/utils.md @@ -0,0 +1,22 @@ +# Shikshalokam Utilities + +## Overview + +The `utils` module in the Shikshalokam application contains diverse utility functions crucial for enabling business workflows, managing data integrations, and supporting domain-specific operations. + +## Key Utility Modules + +- action_list/: Manages lists of actions related to project workflows. +- base_utils.py: Provides foundational utility functions like data validation and transformation. +- chunks_utils.py: Contains methods to split large data or text into manageable chunks. +- mitra_base_utils.py: Supports Mitra project creation and interaction utilities. +- objective_list/: Facilitates management of project objectives lists. +- project_utils.py: Utility functions related to project entity operations. +- recommendation_utils.py: Logic to generate and manage project recommendations. +- story_utils.py: Provides functions to manage story lifecycles and formatting. +- validation_utils.py: Implements various data and workflow validations. +- wishlist_utils.py: Utilities to support wishlist feature. + +## Role in Application + +These utilities provide reusable logic that reduces code duplication and supports core application functionality. They are extensively used across views, serializers, and admin modules. \ No newline at end of file diff --git a/docs/apps/shikshalokam/views.md b/docs/apps/shikshalokam/views.md new file mode 100644 index 0000000..b153940 --- /dev/null +++ b/docs/apps/shikshalokam/views.md @@ -0,0 +1,41 @@ +# Shikshalokam Views + +## Overview + +The views module in the Shikshalokam app contains the entry points for web and API requests, acting as the boundary between client calls and backend domain logic execution. + +## Detailed Module Descriptions + +### health_views.py +- Provides health check endpoint for service liveness verification. +- Implements lightweight JSON response to confirm service operational status. + +### mitra_views.py +- Contains endpoints supporting Mitra project workflows. +- Functions include paraphrasing, objective generation and validation, title generation, action list generation, and project status update. +- Utilizes Shikshalokam and Chatbot utilities for content processing and response. +- Employs concurrency and robust error handling for API responses. + +### profile_views.py +- Manages profile operations including elevated profile retrieval. +- Provides API endpoint to fetch user profile info by access token. +- Handles error cases for missing tokens or profile fetch failures. + +### project_views.py +- Includes CRUD class-based API view for Project entity listing and creation. +- Provides endpoints for duplicating existing projects. +- Supports ingestion of external project and task data. +- Authenticates requests via JWT token verification. +- Utilizes serializers and utility functions for data handling. + +### story_views.py +- Manages lifecycle of Story entities including creation, updating, media attachment, and deletion. +- Supports multilingual story translations and syncing. +- Handles PDF regeneration for stories. +- Provides standard Django REST Framework CRUD APIs. + +### wishlist_views.py +- Handles user wishlist functionality. +- Provides endpoints to add/remove projects to/from wishlists and retrieve wishlist data. + +Each view module orchestrates request parsing, domain logic execution, and response formatting within its feature domain. \ No newline at end of file diff --git a/docs/backend/enums.md b/docs/backend/enums.md new file mode 100644 index 0000000..9c16d92 --- /dev/null +++ b/docs/backend/enums.md @@ -0,0 +1,652 @@ +# Django Enums + +`chatbot/models/enums.py` + +This document defines all enumeration classes used across the platform. + +Enums ensure consistency, validation, and type safety for status fields, providers, configuration types, and workflow definitions. + +--- + +## 1. ChatStageChoices + +### Purpose + +Represents predefined conversational stages in structured chat flows. + Used in state-machine based bots to control progression. + +### Values + +| Name | Value | +|------|-------| +| WELCOME | Welcome_Strand | +| ACHIEVEMENT_ORIENTATION | Achievement_Orientation | +| COURAGE | Courage_Strand | +| CONTINUOUS_LEARNING | Continuous_Strand | +| CRITICAL_THINKING | Critical_Thinking_Strand | +| PURPOSE | Purpose_Strand | +| THANKYOU | Thank_You_Strand | +| OTHER | Other | + +--- + +## 2. ChatStatus + +### Purpose + +Represents the lifecycle status of a chat session. + Used to track conversation progress and state transitions. + +### Values + +| Name | Value | +|------|-------| +| STARTED | STARTED | +| IN_PROGRESS | IN_PROGRESS | +| COMPLETED | COMPLETED | +| PAUSED | PAUSED | +| RESUME | RESUME | + +--- + +## 3. ChatType + +### Purpose + +Defines supported chat workflow types. + Controls conversation structure and bot behavior. + +### Values + +| Name | Value | +|------|-------| +| guidedReflection | normal | +| oneStepReflection | oneshot | +| shikshaChaupal | shikshalokam_chaupal | +| reflection | reflection | +| creation | creation | +| megaPTM | megaPTM | +| YLC | YLC | +| listeningActivity | listening-activity | +| ParentPerceptionSurvey | parent_perception_survey | +| LCF | lcf | +| LFA | lfa | +| FreeFlow | free_flow | + +--- + +## 4. CompanyBotDynamicContextType + +### Purpose + +Specifies dynamic context generation mechanism. + Supports SQL queries or Python scripts. + +### Values + +| Name | Value | +|------|-------| +| SQL_QUERY | SQL_QUERY | +| PYTHON_SCRIPT | PYTHON_SCRIPT | + +--- + +## 5. CompanyBotTypeChoices + +### Purpose + +Defines architecture type of company bots. + Determines conversation execution strategy. + +### Values + +| Name | Value | +|------|-------| +| SIMPLE | SIMPLE | +| STATE_MACHINE | STATE_MACHINE | +| DATABASE_SIMPLE | DATABASE_SIMPLE | +| INTERVIEW_STATE_MACHINE | INTERVIEW_STATE_MACHINE | + +--- + +## 6. CompanyChatSourceChoices + +### Purpose + +Identifies source platform of a chat session. + Used for analytics and usage tracking. + +### Values + +| Name | Value | +|------|-------| +| WEB | WEB | +| PHONE | PHONE | + +--- + +## 7. EntityStatus + +### Purpose + +Indicates whether an entity is active or inactive. + Supports soft-deletion and visibility control. + +### Values + +| Name | Value | +|------|-------| +| ACTIVE | ACTIVE | +| INACTIVE | INACTIVE | + +--- + +## 8. EntityTypeChoices + +### Purpose + +Marks whether an entity is mandatory or optional. + Used in dynamic validation and schema enforcement. + +### Values + +| Name | Value | +|------|-------| +| MANDATORY | MANDATORY | +| OPTIONAL | OPTIONAL | + +--- + +## 9. FeedbackChoices + +### Purpose + +Captures feedback sentiment classification. + Used for analytics and rating systems. + +### Values + +| Name | Value | +|------|-------| +| POSITIVE | POSITIVE | +| NEGATIVE | NEGATIVE | + +--- + +## 10. FileDisplayMode + +### Purpose + +Controls file visibility scope and permissions. + Determines access for UI and AI processing. + +### Values + +| Name | Value | +|------|-------| +| VISIBLE | visible | +| AI_ONLY | ai_only | +| PRIVATE | private | + +--- + +## 11. FileTypeChoices + +### Purpose + +Supported document file types with utility helpers. + Provides MIME, extension, and validation methods. + +### Values + +| Name | Value | +|------|-------| +| PDF | application/pdf | +| DOC | application/msword | +| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document | +| TXT | text/plain | +| CSV | text/csv | +| XLS | application/vnd.ms-excel | +| XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | + +--- + +## 12. GenderChoices + +### Purpose + +Stores supported gender options. + Used in user demographic information. + +### Values + +| Name | Value | +|------|-------| +| MALE | Male | +| FEMALE | Female | + +--- + +## 13. LLMModel + +### Purpose + +Enumerates all supported AI model identifiers. + Used for dynamic model configuration. + +### Values + +| Name | Value | +|------|-------| +| GPT4 | gpt-4 | +| GPT4_1 | gpt-4.1 | +| GPT4_1_MINI | gpt-4.1-mini | +| GPT4_128K | gpt-4-1106-preview | +| GPT4_TURBO | gpt-4-turbo | +| LLAMA_3_8B_8192 | llama3-8b-8192 | +| LLAMA_3_70B_8192 | llama3-70b-8192 | +| LLAMA_3_1_70B_VERSATILE | llama-3.1-70b-versatile | +| LLAMA_3_1_8B_INSTANT | llama-3.1-8b-instant | +| LLAMA_3_1_70B_INSTRUCT | meta.llama3-1-70b-instruct-v1:0 | +| LLAMA_3_1_8B_INSTRUCT | meta.llama3-1-8b-instruct-v1:0 | +| LLAMA_3_3_70B_INSTRUCT | us.meta.llama3-3-70b-instruct-v1:0 | +| LLAMA_3_3_8B_INSTRUCT | us.meta.llama3-3-8b-instruct-v1:0 | +| MIXTRAL_8X70B_32768 | mixtral-8x7b-32768 | +| GPT4_O | gpt-4o | +| GPT4_O_MINI | gpt-4o-mini | +| LLAMA_3_1_8B_OPS | meta-llama/Meta-Llama-3.1-8B-Instruct | +| GPT5_2 | gpt-5.2 | +| GPT5_2_PRO | gpt-5.2-pro | +| GPT5_MINI | gpt-5-mini | + +--- + +## 14. LLMProvider + +### Purpose + +Lists supported Large Language Model providers. + Determines which AI backend service is used. + +### Values + +| Name | Value | +|------|-------| +| BEDROCK | bedrock | +| BEDROCK_CONVERSE | bedrock/converse | +| OPENAI | openai | + +--- + +## 15. LanguageChoices + +### Purpose + +Lists supported language-region codes. + Used for localization and speech services. + +### Values + +| Name | Value | +|------|-------| +| INDIAN_ENGLISH | en-IN | +| INDIAN_HINDI | hi-IN | +| US_ENGLISH | en-US | +| INDIAN_KANNADA | kn-IN | + +--- + +## 16. MediaTemplateChoices + +### Purpose + +Defines supported media template formats. + Used in content rendering workflows. + +### Values + +| Name | Value | +|------|-------| +| EJS | EJS | +| RAW_TEXT | RAW-TEXT | + +--- + +## 17. MediaTypeChoices + +### Purpose + +Supported MIME types for uploaded media. + Used for validation and content handling. + +### Values + +| Name | Value | +|------|-------| +| PDF | application/pdf | +| TXT | text/plain | +| CSV | text/csv | +| JPEG | image/jpeg | +| PNG | image/png | +| SVG | image/svg+xml | +| WEBP | image/webp | +| HEIF | image/heif | +| HEIC | image/heic | +| XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | + +--- + +## 18. PDFStrategyChoices + +### Purpose + +Lists available PDF generation strategies. + Determines rendering engine implementation. + +### Values + +| Name | Value | +|------|-------| +| HTMLPDF | HTMLPDF | +| PUPPETEER | PUPPETEER | +| HTMLDOCX | HTMLDOCX | +| XLSX | XLSX | + +--- + +## 19. PostProcessOutputMode + +### Purpose + +Controls workflow behavior after postprocessing. + Can skip execution of the next stage. + +### Values + +| Name | Value | +|------|-------| +| NONE | NONE | +| SKIP | SKIP | + +--- + +## 20. PostProcessType + +### Purpose + +Defines postprocessing strategy after LLM response. + Used for response refinement and enhancement. + +### Values + +| Name | Value | +|------|-------| +| NONE | NONE | +| SIMPLE | SIMPLE | +| COMPLEX | COMPLEX | + +--- + +## 21. PreProcessOutputMode + +### Purpose + +Controls behavior after preprocessing stage. + Can skip execution of the current stage. + +### Values + +| Name | Value | +|------|-------| +| NONE | NONE | +| SKIP | SKIP | + +--- + +## 22. PreProcessType + +### Purpose + +Defines preprocessing strategy before LLM execution. + Controls prompt transformation complexity. + +### Values + +| Name | Value | +|------|-------| +| NONE | NONE | +| SIMPLE | SIMPLE | +| COMPLEX | COMPLEX | + +--- + +## 23. ProfileType + +### Purpose + +Defines different user profile roles. + Used for access control and permissions. + +### Values + +| Name | Value | +|------|-------| +| USER | USER | +| MODERATOR | MODERATOR | +| PROSPECT | PROSPECT | + +--- + +## 24. RouteLanguageChoices + +### Purpose + +Maps URL route prefixes to language codes. + Used for multilingual routing configuration. + +### Values + +| Name | Value | +|------|-------| +| ENGLISH | en | +| HINDI | hi | +| KANNADA | kn | +| TELUGU | te | + +--- + +## 25. SessionFlowName + +### Purpose + +Represents predefined session flow identifiers. + Used to control guest, login, and special flows. + +### Values + +| Name | Value | +|------|-------| +| GuestDiscussion | guest-discussion | +| LoginDiscussion | login-discussion | +| GuestMiStory | guest-mi-story | +| ListeningActivity | listening-activity | +| LoginMiStory | login | +| SsoFlow | sso | +| Reflection | reflection | +| megaPTM | megaPTM | +| YLC | YLC | +| ParentPerceptionSurvey | parent_perception_survey | +| creation | creation | + +--- + +## 26. StoryLanguageChoices + +### Purpose + +Lists supported languages for stories. + Used for multilingual story management. + +### Values + +| Name | Value | +|------|-------| +| ENGLISH | en | +| HINDI | hi | +| KANNADA | kn | +| TELUGU | te | + +--- + +## 27. StorySourceChoices + +### Purpose + +Specifies origin of story content. + Tracks AI, user, or third-party sources. + +### Values + +| Name | Value | +|------|-------| +| AI_GENERATED | AI_GENERATED | +| USER_GENERATED | USER_GENERATED | +| THIRD_PARTY | THIRD_PARTY | + +--- + +## 28. StoryStatusChoices + +### Purpose + +Represents lifecycle state of a story. + Used to track processing and completion status. + +### Values + +| Name | Value | +|------|-------| +| PENDING | PENDING | +| COMPLETED | COMPLETED | + +--- + +## 29. TagChoices + +### Purpose + +Defines moderation status for tags. + Used in approval and publishing workflows. + +### Values + +| Name | Value | +|------|-------| +| APPROVED | Approved | +| PENDING | Pending | + +--- + +## 30. TagSourceChoices + +### Purpose + +Identifies origin of a tag entry. + Distinguishes manual and AI-based tagging. + +### Values + +| Name | Value | +|------|-------| +| MANUAL | MANUAL | +| AI_EXTRACTED | AI_EXTRACTED | +| AI_GENERATED | AI_GENERATED | + +--- + +## 31. TextConversionType + +### Purpose + +Specifies text transformation operation type. + Supports translation and transliteration modes. + +### Values + +| Name | Value | +|------|-------| +| TRANSLATE | TRANSLATE | +| TRANSLITERATE | TRANSLITERATE | + +--- + +## 32. ThemeType + +### Purpose + +Specifies theme source for a bot instance. + Used to select custom or master UI themes. + +### Values + +| Name | Value | +|------|-------| +| CUSTOM | custom | +| MASTER | master | + +--- + +## 33. VoiceProvider + +### Purpose + +Lists supported speech processing providers. + Used for transcription and voice synthesis services. + +### Values + +| Name | Value | +|------|-------| +| GOOGLE | GOOGLE | +| GOOGLE_V1 | GOOGLE_V1 | +| AI4Bharat | AI4Bharat | +| OPENAI_WHISPER | OPENAI_WHISPER | +| SARVAM | Sarvam | + +--- + +## 34. VoiceProviderChoices + +### Purpose + +Lists supported cloud voice providers. + Used for speech-to-text and text-to-speech services. + +### Values + +| Name | Value | +|------|-------| +| AWS | aws | +| GCP | gcp | +| AZURE | azure | +| ELEVEN_LABS | eleven-labs | + +--- + +## 35. VoiceType + +### Purpose + +Defines type of voice processing operation. + Covers STT, TTS, and transliteration modes. + +### Values + +| Name | Value | +|------|-------| +| SpeechToText | SpeechToText | +| TextToText | TextToText | +| TextToSpeech | TextToSpeech | +| Transliterate | Transliterate | + +--- diff --git a/docs/backend/llm.md b/docs/backend/llm.md new file mode 100644 index 0000000..90cb2af --- /dev/null +++ b/docs/backend/llm.md @@ -0,0 +1,53 @@ +# Large Language Model (LLM) Integration + +## Overview + +The LLM module integrates various large language models into the chatbot system, providing advanced conversational capabilities and story generation. + +## Location + +Located in the `chatbot/llm_models/` directory, the integration includes: + +- `llm_script.py`: Implements core logic for interacting with different LLM providers and managing prompt orchestration. + +## Supported LLM Providers and Features + +The chatbot currently supports multiple LLM providers, each enabling different features: + +### AWS Bedrock + +The Bedrock LLM integration is mainly implemented via the `handle_bedrock_model` function in `llm_script.py`. + +- **Purpose:** Sends conversation prompts to the AWS Bedrock Converse model endpoint and processes the response. +- **Parameters:** + - `messages`: Chat messages and history. + - `max_token`: Max tokens to generate. + - `model_name`: Model identifier. + - `is_json_format`: Whether to expect JSON response. + - `temperature`, `top_p`, `seed`, `n`, `stream`: Controls sampling and streaming. + - `url_to_use`: Optional override of endpoint URL. +- **Output:** Returns parsed JSON content when expecting JSON or raw string otherwise. Handles retries and exceptions internally. + +### OpenAI GPT + +The OpenAI integration is mainly implemented via the `handle_openai_response_api` function in `llm_script.py`. + +- **Purpose:** Manages sending prompts and options to OpenAI API and processing the response. +- **Parameters:** + - `messages`: List of chat messages starting with system prompt. + - `max_token`: Limits max tokens in generated completion. + - `temperature`: Sampling temperature controlling creativity. + - `company_bot`: Optional company bot context. + - `model_name`: Model to use, falls back to company bot's model or default. + - `is_json_response`: If true, parses the completion as JSON. + - `stream`: Enables streaming output. + - `key_name`, `is_actual_key`, `client_choice`: Control API key and client instance. + - `tools`, `tool_choice`: Controls tool integrations. + - `top_p`: Controls nucleus sampling parameter. + - `system_prompt`: Prepended system instructions. +- **Output:** + - For non-stream, returns parsed JSON or string content of completion. + - For stream, yields or returns streaming partial responses (depending on client implementation). + - Raises exceptions on errors to be handled by caller. + +This design enables flexible and powerful LLM interactions with detailed prompt and response control tailored to provider APIs. diff --git a/docs/backend/translate.md b/docs/backend/translate.md new file mode 100644 index 0000000..f191b11 --- /dev/null +++ b/docs/backend/translate.md @@ -0,0 +1,538 @@ +# Translation Integrations + +The Translation layer integrates multiple external language providers and exposes a unified processing interface for: + +- Speech-to-Text (STT) +- Text-to-Speech (TTS) +- Text Translation (T2T) +- Transliteration +- Language Detection + +Each provider implementation resides under: + +``` +chatbot/translate/ +``` + +All provider implementations return a standardized response: + +``` +{ + "status": int, + "content": string +} +``` + +--- + +## 1. AI4Bharat (Bhashini / ULCA) + +Implements multilingual processing using ULCA pipeline APIs. + +Supports: + +- Speech-to-Text +- Text-to-Speech +- Text Translation +- Transliteration +- Language Detection + +--- + +### Service Resolution + +`chatbot/translate/ai4Bharat/base_translation.py` + +#### Purpose + +Handles ULCA model discovery and dynamic service resolution. + +#### Responsibilities + +- Fetch available ULCA models +- Resolve `serviceId` based on: + - taskType + - sourceLanguage + - targetLanguage +- Extract inference API keys +- Provide fallback service mappings + +--- + +### Speech-to-Text (STT) + +`chatbot/translate/ai4Bharat/speech_to_text.py` + +#### Purpose + +Implements Speech-to-Text using ULCA `asr` pipeline. + +#### Responsibilities + +- Accept base64 audio input +- Split large audio into chunks +- Process chunks in parallel +- Merge transcripts in correct order +- Handle sampling rate and audio format configuration + +#### Input + +``` +{ + "base64_audio": string, + "audio_format": string, + "source_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transcribed_text" +} +``` + +--- + +### Text-to-Speech (TTS) + +`chatbot/translate/ai4Bharat/text_to_speech.py` + +#### Purpose + +Implements Text-to-Speech using ULCA `tts` pipeline. + +#### Responsibilities + +- Accept text input +- Configure gender +- Configure sampling rate +- Generate base64 encoded audio + +#### Input + +``` +{ + "text": string, + "source_language": string, + "gender": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "base64_audio" +} +``` + +--- + +### Text Translation (T2T) + +`chatbot/translate/ai4Bharat/text_to_text.py` + +#### Purpose + +Implements language-to-language translation. + +#### Responsibilities + +- Accept source and target languages +- Execute ULCA `translation` task +- Extract translated output + +#### Input + +``` +{ + "text": string, + "source_language": string, + "target_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "translated_text" +} +``` + +--- + +### Transliteration + +`chatbot/translate/ai4Bharat/transliterate.py` + +#### Purpose + +Implements script-level transliteration. + +#### Responsibilities + +- Resolve transliteration serviceId +- Convert between scripts +- Support sentence-level transliteration + +#### Input + +``` +{ + "text": string, + "source_language": string, + "target_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transliterated_text" +} +``` + +--- + +### Language Detection + +`chatbot/translate/ai4Bharat/text_lang_detect.py` + +#### Purpose + +Detects language of input text. + +#### Responsibilities + +- Execute ULCA `txt-lang-detection` +- Extract ISO language code + +#### Input + +``` +{ + "text": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "language_code" +} +``` + +--- + +## 2. Google Cloud + +Implements language services using official Google Cloud SDK clients. + +Supports: + +- Speech-to-Text (v1 & v2) +- Text Translation +- Text-to-Speech + +--- + +### Speech-to-Text (STT) – v1 + +`chatbot/translate/google/google_stt_v1.py` + +#### Purpose + +Performs long-running speech recognition using Speech v1 API. + +#### Responsibilities + +- Decode base64 audio +- Support multiple language codes +- Aggregate recognition results + +#### Input + +``` +{ + "base64_audio": string, + "language_codes": [string] +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transcribed_text" +} +``` + +--- + +### Speech-to-Text (STT) – v2 + +`chatbot/translate/google/google_stt.py` + +#### Purpose + +Performs chunked speech recognition using Speech v2 API. + +#### Responsibilities + +- Split audio into chunks +- Parallel chunk transcription +- Use `latest_long` recognition model +- Merge transcripts in order + +#### Input + +``` +{ + "project_id": string, + "base64_audio": string, + "language_codes": [string] +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transcribed_text" +} +``` + +--- + +### Text Translation (T2T) + +`chatbot/translate/google/google_translate.py` + +#### Purpose + +Performs text translation using Google Translation API. + +#### Responsibilities + +- Authenticate via service account +- Translate text between languages +- Return translated text + +#### Input + +``` +{ + "text": string, + "project_id": string, + "source_language": string, + "target_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "translated_text" +} +``` + +--- + +### Text-to-Speech (TTS) + +`chatbot/translate/google/google_tts.py` + +#### Purpose + +Performs text-to-speech synthesis. + +#### Responsibilities + +- Accept text input +- Configure voice name +- Configure gender +- Configure speaking rate +- Generate MP3 audio + +#### Input + +``` +{ + "text": string, + "language_code": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "base64_audio" +} +``` + +--- + +## 3. OpenAI + +Currently supports Whisper-based speech recognition. + +--- + +### Speech-to-Text (STT) + +`chatbot/translate/openai/openai_stt.py` + +#### Purpose + +Performs speech-to-text using OpenAI Whisper. + +#### Responsibilities + +- Decode base64 audio +- Convert to in-memory file +- Call Whisper model (`whisper-1`) +- Return transcription text + +#### Input + +``` +{ + "base64_audio": string, + "audio_format": string, + "source_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transcribed_text" +} +``` + +--- + +## 4. Sarvam AI + +Implements speech and translation using SarvamAI SDK. + +Supports: + +- Speech-to-Text +- Text Translation + +--- + +### Speech-to-Text (STT) + +`chatbot/translate/sarvam/speech_to_text.py` + +#### Purpose + +Performs chunked speech recognition using SarvamAI. + +#### Responsibilities + +- Decode base64 audio +- Split audio into chunks +- Parallel chunk transcription +- Use `saarika:v2` model +- Merge transcripts + +#### Input + +``` +{ + "base64_audio": string, + "source_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "transcribed_text" +} +``` + +--- + +### Text Translation (T2T) + +`chatbot/translate/sarvam/translate.py` + +#### Purpose + +Performs parallel chunked text translation. + +#### Responsibilities + +- Split long text safely +- Translate chunks in parallel +- Support: + - mode + - output_script + - preprocessing +- Reassemble final output + +#### Input + +``` +{ + "text": string, + "source_language": string, + "target_language": string +} +``` + +#### Output + +``` +{ + "status": 200, + "content": "translated_text" +} +``` + +--- + +## 5. Shared Audio Utilities + +### Audio Utilities + +`chatbot/translate/base/speech_to_text.py` + +#### Purpose + +Provides reusable audio utilities used across providers. + +#### Responsibilities + +- Detect silent audio chunks +- Split audio into fixed-duration segments +- Ensure consistent chunking logic across + - AI4Bharat + - Google + - Sarvam + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c65b094 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,42 @@ +# Shikshalokam Backend + +## What This System Does + +This backend powers an AI-driven knowledge and conversation platform. + +It enables: + +### 1. Conversational AI Workflows +- Free-flow chat +- Guided discussions +- One-shot responses +- Session persistence +- Multi-language support +- Voice (STT/TTS) + +### 2. Story Generation +- Converts chat sessions into structured stories +- Supports translation +- Generates formatted outputs (PDF) + +### 3. Project Creation +- Converts discussions into structured projects +- Generates timelines +- Creates downloadable reports + +### 4. Knowledge Ingestion System +- Upload documents +- Extract metadata using AI +- Auto-tag content +- Store embeddings in vector DB +- Enable semantic search + +### 5. Media Intelligence +- Advanced search (FTS + Trigram) +- Metadata filtering +- Tag ranking + +### 6. Observability & Evaluation +- LLM test cases +- Evaluation pipelines +- Quality tracking diff --git a/docs/integrations/vector_db/qdrant/api_documentation.md b/docs/integrations/vector_db/qdrant/api_documentation.md new file mode 100644 index 0000000..83f54e9 --- /dev/null +++ b/docs/integrations/vector_db/qdrant/api_documentation.md @@ -0,0 +1,735 @@ +# API Documentation + +Complete API reference for the AI Vector Service. + +## **Base URL** + +``` +Local: http://localhost:8000/api +``` + +## **Health Check** + +### **GET /health** + +Check the health status of the service and its dependencies. + +**Response** + +``` +{ + "status": "healthy", + "services": { + "qdrant": "connected", + "redis": "connected" + } +} +``` + +**Status Codes** + +* 200: Service is healthy +* 503: Service is unhealthy (Qdrant or Redis connection failed) + +--- + +## **Document Management** + +### **POST /documents** + +Upload and process a new document. + +**Request** + +Content-Type: multipart/form-data + +| Field | Type | Required | Description | +| :---- | :---- | :---- | :---- | +| file | File | Yes | Document file (PDF, DOCX, TXT, CSV, XLSX) | +| priority | String | No | Priority level (P1, P2, P3). Default: P1 | +| source\_id | String | No | Unique identifier for the document source | +| company\_id | String | No | Company identifier for multi-tenant scenarios | +| title | String | No | Document title | +| summary | String | No | Document summary | +| metadata | JSON String | No | Additional metadata as JSON object | +| tags | JSON Array or CSV | No | Tags as JSON array or comma-separated string | + +**Example Request (cURL)** + +``` +curl -X POST "http://localhost:8000/api/documents" \ + -F "file=@document.pdf" \ + -F "priority=P1" \ + -F "source_id=doc_123" \ + -F "company_id=company_1" \ + -F "title=Machine Learning Guide" \ + -F "summary=Comprehensive guide to ML algorithms" \ + -F 'tags=["AI", "ML", "Tutorial"]' \ + -F 'metadata={"author": "John Doe", "department": "Engineering"}' +``` + +**Response** + +``` +{ + "message": "Document processed successfully", + "source_id": "doc_123", + "chunks_created": 15, + "metadata": { + "type": "pdf", + "priority": "P1", + "company_id": "company_1" + } +} +``` + +**Status Codes** + +* 201: Document created successfully +* 400: Invalid request (bad file format, invalid metadata) +* 500: Server error during processing + +--- + +### **PUT /documents/{source\_id}** + +Update existing documents by replacing all documents with the same source\_id. + +**Path Parameters** + +* source\_id (string): Source identifier of documents to update + +**Request** + +Content-Type: multipart/form-data + +| Field | Type | Required | Description | +| :---- | :---- | :---- | :---- | +| file | File | Yes | New document file | +| priority | String | No | Priority level. Default: P1 | +| metadata | JSON String | No | Updated metadata | +| company\_id | String | No | Company identifier | + +**Example Request** + +``` +curl -X PUT "http://localhost:8000/api/documents/doc_123" \ + -F "file=@updated_document.pdf" \ + -F "priority=P2" \ + -F "company_id=company_1" +``` + +**Response** + +``` +{ + "message": "Documents updated successfully", + "source_id": "doc_123", + "deleted_count": 15, + "created_count": 18 +} +``` + +--- + +### **PUT /documents/{source\_id}/upsert** + +Upsert documents \- update if exists, create if not. + +**Path Parameters** + +* source\_id (string): Source identifier + +**Request** + +Same as PUT /documents/{source\_id} + +**Response** + +``` +{ + "message": "Documents upserted successfully", + "source_id": "doc_123", + "operation": "updated", + "chunks_count": 18 +} +``` + +--- + +### **PATCH /documents/{source\_id}/metadata** + +Update only metadata of documents without reprocessing content. + +**Path Parameters** + +* source\_id (string): Source identifier + +**Request** + +Content-Type: multipart/form-data + +| Field | Type | Required | Description | +| :---- | :---- | :---- | :---- | +| metadata\_updates | JSON String | Yes | Metadata fields to update | +| company\_id | String | No | Company identifier | + +**Example Request** + +``` +curl -X PATCH "http://localhost:8000/api/documents/doc_123/metadata" \ + -F 'metadata_updates={"author": "Jane Smith", "version": "2.0"}' \ + -F "company_id=company_1" +``` + +**Response** + +``` +{ + "message": "Metadata updated successfully", + "source_id": "doc_123", + "updated_count": 15, + "updated_fields": ["author", "version"] +} +``` + +--- + +### **DELETE /documents/{source\_id}** + +Delete all documents with the specified source\_id. + +**Path Parameters** + +* source\_id (string): Source identifier + +**Request** + +Content-Type: multipart/form-data + +| Field | Type | Required | Description | +| :---- | :---- | :---- | :---- | +| company\_id | String | No | Company identifier for filtering | + +**Example Request** + +``` +curl -X DELETE "http://localhost:8000/api/documents/doc_123" \ + -F "company_id=company_1" +``` + +**Response** + +``` +{ + "message": "Documents deleted successfully", + "source_id": "doc_123", + "deleted_count": 15 +} +``` + +**Status Codes** + +* 200: Documents deleted successfully +* 404: No documents found with the given source\_id +* 500: Server error during deletion + +--- + +## **Search Operations** + +### **POST /documents/search** + +Perform prioritized multi-field search with weighted scoring. + +**Request** + +Content-Type: application/json + +``` +{ + "query": "machine learning algorithms", + "top_k": 10, + "categories": ["AI", "ML"], + "organizations": ["company_1"], + "resource_type": ["Tutorial"], + "file_type": ["pdf"] +} +``` + +**Request Fields** + +| Field | Type | Required | Default | Description | +| :---- | :---- | :---- | :---- | :---- | +| query | String | No | null | Search query text. If not provided, returns all unique documents | +| top\_k | Integer | No | 10 | Number of top results to return (max: 100\) | +| categories | Array\[String\] | No | null | Filter by tags (OR condition) | +| organizations | Array\[String\] | No | null | Filter by metadata.company (OR condition) | +| resource\_type | Array\[String\] | No | null | Filter by metadata.KEY ENTITIES (OR condition) | +| file\_type | Array\[String\] | No | null | Filter by metadata.type (OR condition) | + +**Response** + +``` +{ + "query": "machine learning algorithms", + "total_results": 10, + "top_k": 10, + "results": [ + { + "id": "chunk_uuid_1", + "text": "Machine learning algorithms are...", + "title": "ML Guide", + "summary": "Comprehensive ML guide", + "tags": ["AI", "ML"], + "metadata": { + "source_id": "doc_123", + "company_id": "company_1", + "type": "pdf", + "priority": "P1" + }, + "source_id": "doc_123", + "score": 0.89, + "field_scores": { + "title": 0.92, + "text": 0.87, + "tags": 0.85, + "summary": 0.88, + "metadata": 0.75 + } + } + ], + "search_config": { + "priority_order": ["title", "text", "tags", "summary", "metadata"], + "weights": { + "title": 0.36, + "text": 0.27, + "tags": 0.14, + "summary": 0.14, + "metadata": 0.09 + } + } +} +``` + +**Scoring Formula** + +``` +Final_Score = (W_title × S_title) + (W_text × S_text) + + (W_tags × S_tags) + (W_summary × S_summary) + + (W_metadata × S_metadata) + Multi_field_bonus + +Multi_field_bonus = 0.05 × (matching_fields - 1) +``` + +**Status Codes** + +* 200: Search completed successfully +* 422: Invalid request (top\_k \<= 0\) +* 500: Server error during search + +--- + +### **POST /documents/text-search** + +Simple text embedding search that returns top chunk per unique document. + +**Request** + +Content-Type: application/json + +``` +{ + "query": "machine learning", + "top_k": 5, + "threshold": 0.4 +} +``` + +**Request Fields** + +| Field | Type | Required | Default | Description | +| :---- | :---- | :---- | :---- | :---- | +| query | String | Yes | \- | Search query text | +| top\_k | Integer | No | 10 | Number of unique documents to return | +| threshold | Float | No | 0.40 | Minimum similarity score threshold | + +**Response** + +``` +{ + "query": "machine learning", + "total_results": 5, + "results": [ + { + "source_id": "doc_123", + "text": "Machine learning is a subset of...", + "score": 0.89, + "metadata": { + "title": "ML Guide", + "type": "pdf", + "priority": "P1" + } + } + ] +} +``` + +**Status Codes** + +* 200: Search completed successfully +* 400: Invalid request +* 500: Server error during search + +--- + +### **POST /documents/check-similarity** + +Check if similar content already exists in the database. + +**Request** + +Content-Type: application/json + +``` +{ + "text": "Machine learning is a subset of artificial intelligence", + "company_id": "company_1", + "threshold": 0.85, + "exclude_source_id": "doc_123" +} +``` + +**Request Fields** + +| Field | Type | Required | Default | Description | +| :---- | :---- | :---- | :---- | :---- | +| text | String | Yes | \- | Text to check for similarity | +| company\_id | String | Yes | \- | Company ID to filter by | +| threshold | Float | No | 0.85 | Similarity threshold (0-1) | +| exclude\_source\_id | String | No | null | Source ID to exclude from check | + +**Response** + +``` +{ + "has_similar": true, + "similar_documents": [ + { + "source_id": "doc_456", + "text": "Machine learning, a subset of AI...", + "score": 0.92, + "metadata": { + "title": "AI Basics", + "type": "pdf" + } + } + ] +} +``` + +**Status Codes** + +* 200: Check completed successfully +* 400: Invalid request +* 500: Server error during check + +--- + +## **Query Operations** + +### **POST /query/** + +Query documents with multilingual support and automatic translation. + +**Request** + +Content-Type: application/json + +``` +{ + "query": "What is machine learning?", + "search_limit": 5, + "priority_filter": "P1" +} +``` + +**Request Fields** + +| Field | Type | Required | Default | Description | +| :---- | :---- | :---- | :---- | :---- | +| query | String | Yes | \- | Query text (English or Hindi) | +| search\_limit | Integer | No | 1 | Number of results to return | +| priority\_filter | String | No | null | Filter by priority (P1, P2, P3) | + +**Response** + +``` +{ + "relevant_texts": [ + { + "qdrant_recommendation_text": "Machine learning is...", + "translated_text": null, + "relevance_score": 0.89, + "metadata": { + "source_id": "doc_123", + "type": "pdf", + "priority": "P1" + }, + "priority": "P1", + "chunk_id": "abc123" + } + ], + "original_query": "What is machine learning?", + "translated_query": null, + "language": "en" +} +``` + +**Hindi Query Example** + +Request: + +``` +{ + "query": "मशीन लर्निंग क्या है?", + "search_limit": 3 +} +``` + +Response: + +``` +{ + "relevant_texts": [...], + "original_query": "मशीन लर्निंग क्या है?", + "translated_query": "What is machine learning?", + "language": "hi" +} +``` + +**Status Codes** + +* 200: Query completed successfully +* 400: Invalid request +* 500: Server error during query processing + +--- + +## **Cache Management** + +### **DELETE /cache/clear** + +Clear all cached query results from Redis. + +**Request** + +No request body required. + +**Example Request** + +``` +curl -X DELETE "http://localhost:8000/api/cache/clear" +``` + +**Response** + +``` +{ + "message": "Cache cleared successfully" +} +``` + +**Status Codes** + +* 200: Cache cleared successfully +* 500: Server error during cache clear + +--- + +## **Request/Response Models** + +### **DocumentMetadata** + +``` +{ + "source": "string", + "page": 1, + "row": 5 +} +``` + +### **SearchResultItem** + +``` +{ + "id": "string", + "text": "string", + "title": "string", + "summary": "string", + "tags": ["string"], + "metadata": {}, + "source_id": "string", + "score": 0.89, + "field_scores": { + "title": 0.92, + "text": 0.87 + } +} +``` + +### **PrioritizedSearchRequest** + +``` +{ + "query": "string", + "top_k": 10, + "categories": ["string"], + "organizations": ["string"], + "resource_type": ["string"], + "file_type": ["string"] +} +``` + +### **PrioritizedSearchResponse** + +``` +{ + "query": "string", + "total_results": 10, + "top_k": 10, + "results": [SearchResultItem], + "search_config": {} +} +``` + +--- + +## **Error Handling** + +### **Error Response Format** + +``` +{ + "detail": "Error message describing what went wrong" +} +``` + +### **Common Error Codes** + +| Status Code | Description | +| :---- | :---- | +| 400 | Bad Request \- Invalid input data | +| 404 | Not Found \- Resource doesn't exist | +| 422 | Unprocessable Entity \- Validation error | +| 500 | Internal Server Error \- Server-side error | +| 503 | Service Unavailable \- Dependency failure | + +### **Example Error Responses** + +**400 Bad Request** + +``` +{ + "detail": "Invalid metadata JSON: Expecting property name enclosed in double quotes" +} +``` + +**404 Not Found** + +``` +{ + "detail": "No documents found with source_id: doc_123" +} +``` + +**500 Internal Server Error** + +``` +{ + "detail": "Failed to generate embeddings: Connection timeout" +} +``` + +**503 Service Unavailable** + +``` +{ + "detail": "Redis connection failed" +} +``` + +--- + +## **Rate Limiting** + +Currently, there is no rate limiting implemented. For production deployments, consider implementing rate limiting at the API gateway or application level. + +## **API Versioning** + +The API uses URL path versioning: + +* Current version: /api/v1/ +* Future versions: /api/v2/, /api/v3/, etc. + +--- + +## **Best Practices** + +### **1\. Document Upload** + +* Use descriptive source\_id values +* Include relevant metadata for better search results +* Add tags for categorization +* Provide title and summary when available + +### **2\. Search Operations** + +* Start with top\_k=10 and adjust based on results +* Use filters to narrow down results +* Combine multiple search strategies for best results +* Cache frequently used queries + +### **3\. Metadata Management** + +* Use consistent metadata schema across documents +* Include company\_id for multi-tenant scenarios +* Update metadata separately when content doesn't change + +### **4\. Error Handling** + +* Always check response status codes +* Implement retry logic for 500 errors +* Validate input data before sending requests +* Handle partial failures in batch operations + +--- + +## **Examples** + +### **Complete Upload and Search Workflow** + +``` +# 1. Upload a document +curl -X POST "http://localhost:8000/api/documents" \ + -F "file=@ml_guide.pdf" \ + -F "source_id=ml_guide_001" \ + -F "title=Machine Learning Guide" \ + -F 'tags=["AI", "ML", "Tutorial"]' + +# 2. Search for the document +curl -X POST "http://localhost:8000/api/documents/search" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "neural networks", + "top_k": 5, + "categories": ["AI"] + }' + +# 3. Update metadata +curl -X PATCH "http://localhost:8000/api/documents/ml_guide_001/metadata" \ + -F 'metadata_updates={"version": "2.0", "reviewed": true}' + +# 4. Delete the document +curl -X DELETE "http://localhost:8000/api/documents/ml_guide_001" +``` diff --git a/docs/integrations/vector_db/qdrant/developer_guide.md b/docs/integrations/vector_db/qdrant/developer_guide.md new file mode 100644 index 0000000..15e47b9 --- /dev/null +++ b/docs/integrations/vector_db/qdrant/developer_guide.md @@ -0,0 +1,646 @@ +# Developer Guide + +This guide provides detailed instructions for developers who want to contribute to or extend the AI Vector Service. + +## **Development Setup** + +### **Prerequisites** + +* Python 3.8 or higher +* pip (Python package manager) +* Git +* Qdrant (vector database) +* Redis (cache server) +* PostgreSQL (optional, for translation features) + +### **Setting Up Development Environment** + +#### **1\. Clone the Repository** + +``` +git clone +cd ai-vector-service +``` + +#### + +#### + +#### + +#### **2\. Create Virtual Environment** + +``` +# Create virtual environment +python3 -m venv .venv + +# Activate virtual environment +# On macOS/Linux: +source .venv/bin/activate +``` + +#### **3\. Install Dependencies** + +``` +# Install all dependencies +pip install -r requirements.txt + +# Verify installation +pip list +``` + +#### **4\. Install Qdrant** + +**Option 1: Using Docker (Recommended)** + +``` +docker pull qdrant/qdrant +docker run -p 6333:6333 -p 6334:6334 \ -v qdrant_data:/qdrant/storage qdrant/qdrant +``` + +**Option 2: Local Installation** + +Follow instructions at: [https://qdrant.tech/documentation/quick-start/](https://qdrant.tech/documentation/quick-start/) + +#### **5\. Install Redis** + +**On macOS:** + +``` +brew install redis +brew services start redis +``` + +**On Ubuntu/Debian:** + +``` +sudo apt-get install redis-server +sudo systemctl start redis +``` + +**Using Docker:** + +``` +docker run -d -p 6379:6379 redis:latest +``` + +#### **6\. Install PostgreSQL (Optional)** + +**On macOS:** + +``` +brew install postgresql +brew services start postgresql +``` + +**On Ubuntu/Debian:** + +``` +sudo apt-get install postgresql +sudo systemctl start postgresql +``` + +#### **7\. Configure Environment** + +``` +# Copy sample environment file +cp .env.sample .env + +# Edit .env with your configuration +nano .env # or use your preferred editor +``` + +**Minimum Configuration:** + +``` +# Qdrant +QDRANT_HOST=127.0.0.1 +QDRANT_PORT=6333 + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Environment +ENVIRONMENT=local +``` + +#### **8\. Verify Setup** + +``` +# Start the application +python -m uvicorn app.main:app --reload + +# In another terminal, test health endpoint +curl http://localhost:8000/api/health +``` + +Expected output: + +``` +{ + "status": "healthy", + "services": { + "qdrant": "connected", + "redis": "connected" + } +} +``` + +## + +## + +## **Project Structure** + +``` +ai-vector-service/ +├── app/ # Main application package +│ ├── api/ # API layer +│ │ └── v1/ # API version 1 +│ │ ├── endpoints/ # API endpoint handlers +│ │ │ ├── documents.py # Document management endpoints +│ │ │ ├── query.py # Query endpoints +│ │ │ └── cache.py # Cache management endpoints +│ │ └── api.py # API router configuration +│ │ +│ ├── core/ # Core functionality +│ │ ├── clients/ # External service clients +│ │ │ ├── qdrant.py # Qdrant client and operations +│ │ │ ├── redis_cache.py # Redis cache implementation +│ │ │ └── embedding.py # Embedding model wrapper +│ │ └── database.py # Database connection +│ │ +│ ├── models/ # Data models +│ │ ├── api_models.py # Pydantic models for API +│ │ └── db_models.py # SQLAlchemy models for DB +│ │ +│ ├── services/ # Business logic +│ │ ├── document_operations/ # Document CRUD operations +│ │ │ ├── base_operation.py +│ │ │ ├── upload_service.py +│ │ │ ├── update_service.py +│ │ │ ├── delete_service.py +│ │ │ └── metadata_service.py +│ │ │ +│ │ ├── file_processors/ # File format processors +│ │ │ ├── base_processor.py +│ │ │ ├── pdf_processor.py +│ │ │ ├── docx_processor.py +│ │ │ ├── text_processor.py +│ │ │ ├── csv_processor.py +│ │ │ └── xlsx_processor.py +│ │ │ +│ │ ├── document_processor.py # Main document processor +│ │ ├── query_service.py # Query processing +│ │ ├── prioritized_search_service.py +│ │ ├── text_embedding_search_service.py +│ │ ├── similarity_service.py +│ │ └── url_text_extractor.py +│ │ +│ ├── utils/ # Utility functions +│ │ ├── json_handler.py +│ │ └── language_utils.py +│ │ +│ ├── config.py # Configuration settings +│ └── main.py # Application entry point +│ +├── tests/ # Test suite +│ ├── conftest.py # Test configuration +│ ├── test_api.py # API tests +│ └── logger/ # Test logging +│ +├── scripts/ # Utility scripts +│ ├── insert_document.py # Document insertion script +│ ├── data_insert_script.py # Batch data insertion +│ └── quick_insert_example.py # Quick test script +│ +├── .env.sample # Environment variables template +├── .gitignore # Git ignore rules +├── requirements.txt # Python dependencies +├── pytest.ini # Pytest configuration +└── README.md # Project documentation +``` + +## **Development Workflow** + +### **1\. Create a Feature Branch** + +``` +git checkout -b feature/your-feature-name +``` + +### **2\. Make Changes** + +Edit code, add features, fix bugs, etc. + +### **3\. Run Tests** + +``` +# Run all tests +pytest + +# Run specific test file +pytest tests/test_api.py + +# Run with coverage +pytest --cov=app --cov-report=html +``` + +### **4\. Check Code Quality** + +``` +# Format code (if using black) +black app/ + +# Check linting (if using flake8) +flake8 app/ + +# Type checking (if using mypy) +mypy app/ +``` + +### **5\. Commit Changes** + +``` +git add . +git commit -m "feat: add new feature description" +``` + +### **6\. Push and Create Pull Request** + +``` +git push origin feature/your-feature-name +``` + +## **Code Style and Standards** + +### **Python Style Guide** + +Follow PEP 8 style guide: + +* Use 4 spaces for indentation +* Maximum line length: 100 characters +* Use descriptive variable names +* Add docstrings to all functions and classes + +### **Naming Conventions** + +* **Files**: snake\_case.py +* **Classes**: PascalCase +* **Functions**: snake\_case +* **Constants**: UPPER\_CASE +* **Private methods**: \_leading\_underscore + +### **Example Code Style** + +``` +from typing import List, Dict, Any +import logging + +logger = logging.getLogger(__name__) + +class DocumentProcessor: + """Process documents and generate embeddings. + + This class handles document upload, processing, and storage + in the vector database. + """ + + def __init__(self): + """Initialize the document processor.""" + self.chunk_size = settings.CHUNK_SIZE + self.chunk_overlap = settings.CHUNK_OVERLAP + + def process_document( + self, + file_path: str, + metadata: Dict[str, Any] + ) -> List[Dict[str, Any]]: + """Process a document and return chunks. + + Args: + file_path: Path to the document file + metadata: Document metadata + + Returns: + List of document chunks with embeddings + + Raises: + ValueError: If file format is not supported + """ + logger.info(f"Processing document: {file_path}") + + # Implementation here + + return chunks +``` + +``` + +``` + +## **Testing** + +### **Running Tests** + +``` +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run specific test file +pytest tests/test_api.py + +# Run specific test function +pytest tests/test_api.py::test_upload_document + +# Run with coverage +pytest --cov=app --cov-report=html + +# View coverage report +open htmlcov/index.html +``` + +### **Writing Tests** + +Create test files in the tests/ directory: + +``` +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_health_check(): + """Test health check endpoint.""" + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + +def test_upload_document(): + """Test document upload.""" + with open("test_file.pdf", "rb") as f: + files = {"file": f} + data = {"source_id": "test_001", "priority": "P1"} + response = client.post("/api/documents", files=files, data=data) + + assert response.status_code == 201 + assert "chunks_created" in response.json() + +@pytest.fixture +def sample_document(): + """Fixture for sample document.""" + return { + "source_id": "test_doc", + "content": "Sample content", + "metadata": {"type": "pdf"} + } + +def test_with_fixture(sample_document): + """Test using fixture.""" + assert sample_document["source_id"] == "test_doc" +``` + +## + +## + +## + +## **Debugging** + +### **Logging** + +The application uses Python's built-in logging: + +``` +import logging + +logger = logging.getLogger(__name__) + +# Log levels +logger.debug("Debug message") +logger.info("Info message") +logger.warning("Warning message") +logger.error("Error message") +logger.critical("Critical message") +``` + +### **Debug Mode** + +Run the application in debug mode: + +``` +# With uvicorn reload +python -m uvicorn app.main:app --reload --log-level debug + +# With Python debugger +python -m pdb -m uvicorn app.main:app +``` + +### **Using Debugger** + +Add breakpoints in code: + +``` +import pdb + +def some_function(): + # Code here + pdb.set_trace() # Breakpoint + # More code +``` + +### + +### **Checking Qdrant** + +``` +# View Qdrant dashboard +open http://localhost:6333/dashboard + +# Check collections via API +curl http://localhost:6333/collections +``` + +### **Checking Redis** + +``` +# Connect to Redis CLI +redis-cli + +# Check keys +KEYS * + +# Get specific key +GET query:abc123 + +# Clear all keys +FLUSHALL +``` + +## **Common Development Tasks** + +### **Running the Application** + +``` +# Development mode with auto-reload +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Production mode +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +### **Updating Dependencies** + +``` +# Add new dependency +pip install package-name + +# Update requirements.txt +pip freeze > requirements.txt + +# Install from requirements.txt +pip install -r requirements.txt +``` + +### **Database Migrations (if using Alembic)** + +``` +# Create migration +alembic revision --autogenerate -m "description" + +# Apply migration +alembic upgrade head + +# Rollback migration +alembic downgrade -1 +``` + +### **Clearing Test Data** + +``` +# Clear Qdrant collections +python scripts/clear_collections.py + +# Clear Redis cache +redis-cli FLUSHALL + +# Clear PostgreSQL data +psql -d ai_vector_service -c "TRUNCATE TABLE translation_records;" +``` + +### **Generating API Documentation** + +FastAPI automatically generates API documentation: + +* Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) +* ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc) +* OpenAPI JSON: [http://localhost:8000/openapi.json](http://localhost:8000/openapi.json) + +### **Performance Profiling** + +``` +import cProfile +import pstats + +def profile_function(): + profiler = cProfile.Profile() + profiler.enable() + + # Code to profile + + profiler.disable() + stats = pstats.Stats(profiler) + stats.sort_stats('cumulative') + stats.print_stats() +``` + +## **Environment Variables** + +All configuration is in .env file: + +``` +# Qdrant Configuration +QDRANT_HOST=127.0.0.1 +QDRANT_PORT=6333 +COLLECTION_NAME=documents +QA_CACHE_COLLECTION=qa_cache + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_CACHE_TTL=86400 +REDIS_MAX_CACHE_SIZE=1000 + +# Model Configuration +EMBEDDING_MODEL=all-MiniLM-L6-v2 +LLAMA_MODEL_ID=meta.llama3-70b-instruct-v1:0 + +# Chunking Configuration +CHUNK_SIZE=3000 +CHUNK_OVERLAP=500 +MARKDOWN_CHUNK_SIZE=3500 +MARKDOWN_CHUNK_OVERLAP=800 + +# Search Configuration +SIMILARITY_THRESHOLD=0.40 +VECTOR_SEARCH_LIMIT=1 +DEFAULT_SEARCH_TOP_K=10 +MAX_SEARCH_TOP_K=100 + +# Database Configuration +POSTGRES_DATABASE_URI=postgresql://user:pass@localhost:5432/ai_vector_service + +# Environment +ENVIRONMENT=local +``` + +## + +## **Troubleshooting** + +### **Common Issues** + +**Issue: Import errors** + +``` +# Solution: Ensure virtual environment is activated +source .venv/bin/activate +pip install -r requirements.txt +``` + +**Issue: Qdrant connection failed** + +``` +# Solution: Check if Qdrant is running +curl http://localhost:6333/collections + +# Start Qdrant if not running +docker start qdrant # if using Docker +``` + +**Issue: Redis connection failed** + +``` +# Solution: Check if Redis is running +redis-cli ping + +# Start Redis if not running +brew services start redis # macOS +sudo systemctl start redis # Linux +``` + +**Issue: Tests failing** + +``` +# Solution: Clear test data +redis-cli FLUSHALL +# Restart Qdrant +``` + diff --git a/docs/integrations/vector_db/qdrant/system_architecture.md b/docs/integrations/vector_db/qdrant/system_architecture.md new file mode 100644 index 0000000..5dfcf01 --- /dev/null +++ b/docs/integrations/vector_db/qdrant/system_architecture.md @@ -0,0 +1,325 @@ + +# System Architecture + +This document provides a comprehensive overview of the AI Vector Service architecture, including system components, data flow, and design decisions. + +## **System Overview** + +The AI Vector Service is a microservice-based application built on FastAPI that provides intelligent document processing and semantic search capabilities. The system uses vector embeddings to enable semantic similarity search across multiple document types. + +## **Core Components** + +### **1\. API Layer (app/api/)** + +The API layer handles HTTP requests and responses using FastAPI. + +#### **Endpoints Module (app/api/v1/endpoints/)** + +* **documents.py**: Document management endpoints + * Upload, update, delete documents + * Similarity checking + * Prioritized and text-based search +* **query.py**: Multilingual query processing + * Language detection and translation + * Priority-based search +* **cache.py**: Cache management + * Clear cache operations + +### **2\. Service Layer (app/services/)** + +The service layer contains business logic and orchestrates operations. + +#### **Document Operations (document\_operations/)** + +* **base\_operation.py**: Base class for document operations +* **upload\_service.py**: Handles document upload and processing +* **update\_service.py**: Updates existing documents +* **delete\_service.py**: Deletes documents from vector store +* **metadata\_service.py**: Updates document metadata + +#### **File Processors (file\_processors/)** + +Each processor handles a specific file format: + +* **base\_processor.py**: Abstract base class for file processors +* **pdf\_processor.py**: PDF document processing +* **docx\_processor.py**: Microsoft Word document processing +* **text\_processor.py**: Plain text and markdown processing +* **csv\_processor.py**: CSV file processing +* **xlsx\_processor.py**: Excel spreadsheet processing + +#### **Search Services** + +* **query\_service.py**: Multilingual query processing with translation +* **prioritized\_search\_service.py**: Multi-field weighted search +* **text\_embedding\_search\_service.py**: Simple text embedding search +* **similarity\_service.py**: Content similarity detection + +#### **Other Services** + +* **document\_processor.py**: Main document processing orchestrator +* **url\_text\_extractor.py**: Extracts text from web URLs +* **translation\_service.py**: Text translation service + +### **3\. Core Layer (app/core/)** + +The core layer provides foundational services and clients. + +#### **Clients (core/clients/)** + +* **qdrant.py**: Qdrant vector database client + * Collection management + * Batch upload operations + * Named vectors configuration +* **redis\_cache.py**: Redis LRU cache implementation + * Query result caching + * Automatic eviction of old entries + * Configurable TTL and size limits +* **embedding.py**: Sentence transformer embedding model + * Generates vector embeddings for text + +#### **Database (core/database.py)** + +* PostgreSQL database connection +* Used for storing translation records + +### **4\. Models (app/models/)** + +* **api\_models.py**: Pydantic models for API requests/responses +* **db\_models.py**: SQLAlchemy models for database tables + +### **5\. Utilities (app/utils/)** + +* **json\_handler.py**: Custom JSON response handling +* **language\_utils.py**: Language detection and translation utilities + +## **Data Flow** + +### **Document Upload Flow** + +``` +1. Client uploads file + ↓ +2. API endpoint receives file + metadata + ↓ +3. DocumentProcessor validates file type + ↓ +4. Appropriate FileProcessor processes file + ↓ +5. Text is chunked using LangChain + ↓ +6. Embeddings generated for each chunk + ↓ +7. Multiple named vectors created: + - text: chunk content embedding + - title: document title embedding + - summary: document summary embedding + - tags: tags embedding + - metadata: metadata embedding + ↓ +8. Points uploaded to Qdrant in batches + ↓ +9. Response returned to client +``` + +### **Search Flow (Prioritized Search)** + +``` +1. Client sends search query + filters + ↓ +2. Query embedding generated + ↓ +3. Search across all named vectors: + - title vector search + - text vector search + - tags vector search + - summary vector search + - metadata vector search + ↓ +4. Apply filters (categories, organizations, etc.) + ↓ +5. Calculate weighted scores: + Final Score = (W_title × S_title) + + (W_text × S_text) + + (W_tags × S_tags) + + (W_summary × S_summary) + + (W_metadata × S_metadata) + ↓ +6. Apply multi-field bonus (5% per additional field) + ↓ +7. Group by source_id, keep highest score + ↓ +8. Sort and return top_k results +``` + +### **Multilingual Query Flow** + +``` +1. Client sends query (any language) + ↓ +2. Language detection (English/Hindi) + ↓ +3. If Hindi: Translate to English + ↓ +4. Check Redis cache + ↓ +5. If cache miss: + a. Generate query embedding + b. Search Qdrant with priority filtering + c. Process results + d. If Hindi query: Include translations + e. Cache response + ↓ +6. Return results to client +``` + +## **Vector Storage Strategy** + +### **Named Vectors Architecture** + +The system uses Qdrant's named vectors feature to store multiple embeddings per document chunk: + +``` +{ + "id": "chunk_uuid", + "vectors": { + "text": [0.1, 0.2, ...], # Chunk content embedding + "title": [0.3, 0.4, ...], # Document title embedding + "summary": [0.5, 0.6, ...], # Document summary embedding + "tags": [0.7, 0.8, ...], # Tags embedding + "metadata": [0.9, 1.0, ...] # Metadata embedding + }, + "payload": { + "text": "chunk content", + "title": "document title", + "summary": "document summary", + "tags": ["tag1", "tag2"], + "metadata": { + "source_id": "doc_123", + "company_id": "company_1", + "priority": "P1", + "type": "pdf", + ... + } + } +} +``` + +### **Benefits of Named Vectors** + +1. **Multi-field Search**: Search across different document aspects simultaneously +2. **Weighted Scoring**: Apply different weights to different fields +3. **Flexible Querying**: Choose which vectors to search based on use case +4. **Better Relevance**: Combine signals from multiple fields for better results + +### **Collections** + +1. **documents** (main collection) + * Stores all document chunks with named vectors + * Supports multi-field search +2. **qa\_cache** (cache collection) + * Stores cached query-answer pairs + * Uses single vector for similarity matching + +## **Caching Strategy** + +### **Redis LRU Cache** + +The system implements a Least Recently Used (LRU) cache using Redis: + +#### **Cache Key Generation** + +* Keys are generated using MD5 hash of query \+ filters +* Format: query:\ + +#### **Access Tracking** + +* Uses Redis Sorted Set (lru:access\_list) +* Scores are timestamps of last access +* Automatically updates on cache hits + +#### **Eviction Policy** + +* When cache size exceeds REDIS\_MAX\_CACHE\_SIZE +* Oldest entries (lowest timestamps) are removed +* Both cache entry and access list entry deleted + +#### **TTL (Time To Live)** + +* Configurable via REDIS\_CACHE\_TTL +* Default: 86400 seconds (24 hours) +* Automatic expiration of stale data + +#### **Cache Operations** + +``` +# Get from cache +cached_result = redis_cache.get(query) + +# Set in cache +redis_cache.set(query, response) + +# Clear all cache +redis_cache.clear() + +# Remove specific entry +redis_cache.remove(query) +``` + +## **Search Architecture** + +### **1\. Prioritized Multi-field Search** + +**Purpose**: Comprehensive search across all document fields with weighted scoring + +**Features**: + +* Searches across 5 named vectors (title, text, tags, summary, metadata) +* Configurable weights for each field +* Multi-field bonus for documents matching multiple fields +* Advanced filtering (categories, organizations, resource types, file types) +* Returns unique documents (one per source\_id) + +**Scoring Formula**: + +``` +Final_Score = (W_title × S_title) + + (W_text × S_text) + + (W_tags × S_tags) + + (W_summary × S_summary) + + (W_metadata × S_metadata) + + (Multi_field_bonus) + +Multi_field_bonus = 0.05 × (number_of_matching_fields - 1) +``` + +**Default Weights** (configurable in config.py): + +* Title: 36% +* Text: 27% +* Tags: 14% +* Summary: 14% +* Metadata: 9% + +### **2\. Text Embedding Search** + +**Purpose**: Simple, fast text-based search + +**Features**: + +* Searches only the text vector +* Returns top chunk per unique document +* Configurable similarity threshold +* Faster than prioritized search + +### **3\. Multilingual Query Search** + +**Purpose**: Support queries in multiple languages + +**Features**: + +* Automatic language detection +* Translation to English for vector search +* Priority-based filtering (P1, P2, P3) +* Returns results with translations if needed +* Redis caching for performance diff --git a/docs/integrations/vector_db/qdrant/testing_guide.md b/docs/integrations/vector_db/qdrant/testing_guide.md new file mode 100644 index 0000000..699af50 --- /dev/null +++ b/docs/integrations/vector_db/qdrant/testing_guide.md @@ -0,0 +1,86 @@ +# Testing Guide + +Comprehensive testing guide for the AI Vector Service project. + +### **Test Dependencies** + +Required packages (from requirements.txt): + +* pytest \- Testing framework +* pytest-cov \- Coverage reporting +* pytest-asyncio \- Async test support + +### **Test Structure** + +``` +tests/ +├── __init__.py +├── conftest.py # Shared fixtures and configuration +├── test_api.py # API endpoint tests +└── logger/ + └── test_logger.py # Test logging utilities +``` + +## **Running Tests** + +### **Basic Test Execution** + +``` +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run specific test file +pytest tests/test_api.py + +# Run specific test class +pytest tests/test_api.py::TestHealthCheck + +# Run specific test function +pytest tests/test_api.py::TestHealthCheck::test_health_check_success +``` + +### **Coverage Reports** + +``` +# Run tests with coverage +pytest --cov=app + +# Generate HTML coverage report +pytest --cov=app --cov-report=html + +# View coverage report +open htmlcov/index.html # macOS +xdg-open htmlcov/index.html # Linux +``` + +### **Coverage Output Formats** + +``` +# Terminal output with missing lines +pytest --cov=app --cov-report=term-missing + +# XML report (for CI/CD) +pytest --cov=app --cov-report=xml + +# HTML report (for detailed analysis) +pytest --cov=app --cov-report=html +``` + +### **Running Specific Tests** + +``` +# Run tests matching a pattern +pytest -k "health" + +# Run tests with specific markers (if defined) +pytest -m "slow" + +# Run failed tests from last run +pytest --lf + +# Run failed tests first, then others +pytest --ff +``` diff --git a/docs/setup/developer_setup.md b/docs/setup/developer_setup.md new file mode 100644 index 0000000..a4912cd --- /dev/null +++ b/docs/setup/developer_setup.md @@ -0,0 +1,294 @@ +# Shikshalokam Mohini Service – Local Setup +--- + +## Prerequisites + +* macOS +* Homebrew installed +* Python 3.10 +* Git + +--- + +## 1. Install Python 3.10 and uv Dependency Manager + +```bash +brew install python@3.10 +``` + +Verify installation: + +```bash +python3.10 --version +``` + +Install uv: +```base +pip install uv +``` + +--- + +## 2. Create a Virtual Environment (Outside Project Directory) + +Assuming your project is located at: + +``` +/Users/kunal/PycharmProjects/shikshalokam-mohini-service +``` + +### Step 1: Go to the project directory + +```bash +cd /Users/kunal/PycharmProjects/shikshalokam-mohini-service +``` + +### Step 2: Create the virtual environment + +```bash +uv venv +``` + +### Step 3: Activate the virtual environment + +```bash +source .venv/bin/activate +``` + +--- + +## 3. Install Project Dependencies + +```bash +uv sync +``` + +--- + +## 4. Load Environment Variables + +Make sure you have a `.env` file in the project root. + +```bash +export $(cat .env | xargs) +``` + +> ⚠️ Note: This exports variables only for the current shell session. + +--- + +## 5. Set Up Local PostgreSQL Database + +### 5.1 Install PostgreSQL + +Using Homebrew: + +```bash +brew install postgresql@14 +``` + +Start PostgreSQL: + +```bash +brew services start postgresql@14 +``` + +Verify it’s running: + +```bash +psql --version +``` + +--- + +### 5.2 Create Database and User + +Login to Postgres: + +```bash +psql postgres +``` + +Create a database user: + +```sql +CREATE USER mitra_user WITH PASSWORD 'mitra_password'; +``` + +Create the database: + +```sql +CREATE DATABASE mitra_db OWNER mitra_user; +``` + +Grant privileges: + +```sql +GRANT ALL PRIVILEGES ON DATABASE mitra_db TO mitra_user; +``` + +Exit psql: + +```sql +\q +``` + +--- + +### 5.3 Update `.env` File + +Add or update the following variables in your `.env` file: + +```env +DATABASE_NAME=mitra_db +DATABASE_USER=mitra_user +DATABASE_PASSWORD=mitra_password +DATABASE_HOST=localhost +DATABASE_PORT=5432 +``` + +### 5.4 Install PostgreSQL Python Driver + +Make sure this dependency exists (usually already in `requirements.in`): + +```bash +uv pip install psycopg2-binary +``` + +--- + +### 5.5 Run Django Migrations + +Ensure your virtual environment is active and env vars are loaded: + +```bash +export $(cat .env | xargs) +``` + +Run migrations: + +```bash +python3 manage.py migrate +``` + +(Optional) Create a superuser: + +You can accept the default name and give any password, keep email +empty and just press enter till completed. + +```bash +python3 manage.py createsuperuser +``` + +--- + +## Common Issues + +**Postgres not starting** + +```bash +brew services restart postgresql@14 +``` + +**Role does not exist** + +```bash +psql postgres +\du +``` + +**Port conflict** + +```bash +lsof -i :5432 +``` + + +## 6. Run the Application Server + +```bash +uvicorn shikshalokam_mohini.asgi:application \ + --host 0.0.0.0 \ + --port 9000 \ + --workers 4 \ + --ws-ping-interval 30 \ + --ws-ping-timeout 300 \ + --reload +``` + +--- + +## 7. Run Celery Worker + +Open a new terminal (with the same virtual environment activated): + +```bash +celery -A shikshalokam_mohini worker --pool=threads +``` + +--- + +## Notes + +* Ensure Redis or any other required backing services are running before starting Celery. +* Always activate `mitra_env` before running server or worker commands. + +--- + +Perfect, let’s plug **Redis setup** into the README cleanly 👌 +You can add this as the next section. + +--- + +## 8. Set Up Redis (Local, IF celery gives error) + +Redis is required for Celery and background task processing. + +--- + +### 8.1 Install Redis + +Using Homebrew: + +```bash +brew install redis +``` + +--- + +### 8.2 Start Redis Server + +Start Redis as a background service: + +```bash +brew services start redis +``` +--- + +### 8.3 Verify Redis Is Running + +```bash +redis-cli ping +``` + +Expected output: + +```text +PONG +``` + +--- + +## Common Redis Issues + +**Redis not running** + +```bash +brew services restart redis +``` + +**Port already in use** + +```bash +lsof -i :6379 +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..94e3a87 --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +# This is a sample Python script. + +# Press ⌃R to execute it or replace it with your code. +# Press Double ⇧ to search everywhere for classes, files, tool windows, actions, and settings. + + +def print_hi(name): + # Use a breakpoint in the code line below to debug your script. + print(f'Hi, {name}') # Press ⌘F8 to toggle the breakpoint. + + +# Press the green button in the gutter to run the script. +if __name__ == '__main__': + print_hi('PyCharm') + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..a4ff1d4 --- /dev/null +++ b/manage.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +import django + + +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + django.setup() + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/manage.spec b/manage.spec new file mode 100644 index 0000000..a11253c --- /dev/null +++ b/manage.spec @@ -0,0 +1,153 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +from PyInstaller.utils.hooks import collect_submodules, collect_data_files + +datas = collect_data_files('coreschema') +hiddenimports = ['rest_framework_simplejwt', 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'celery.fixups', + 'rest_framework_simplejwt.state', + 'celery.fixups.django', + 'shikshalokam.serializer', + 'chatbot.models', + 'simple_history.tests.tests', + 'rest_framework.schemas', + 'kombu.utils', + 'django', + 'django.conf', + 'django.core', + 'django.db', + 'django.db.backends', + 'django.db.backends.sqlite3', + 'django.http', + 'django.urls', + 'django.utils.formats', + 'django.utils', + 'importlib', + 'celery', + 'celery.app', + 'celery.app.task', + 'celery.loaders', + 'celery.loaders.app', + 'coreschema', + 'ssl', + 'urls','shikshalokam_mohini.asgi', 'shikshalokam_mohini.urls','channels_redis', + 'channels_redis.core', + 'channels_redis.client', + 'channels_redis.protocol', + 'channels_redis.persistence', + 'channels_redis.exceptions', + 'channels_redis.router', + 'celery.app.amqp', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'celery.concurrency.prefork', + 'celery.apps.worker', + 'jinja', + 'google', + 'google_auth_oauthlib', + 'celery.worker.autoscale', + 'celery.worker.request', + 'celery.worker.consumer', + 'celery.utils.log', + 'celery.utils.dispatch', + 'celery.concurrency', + 'celery.utils.time', + 'celery.utils.imports', + 'celery.app.events', # Add other hidden imports as needed + 'celery.app.log', + 'celery.worker.job', + 'celery.worker.state', + 'celery.worker.strategy', + 'celery.worker.pools', + 'celery.worker.components', + 'celery.worker', + 'celery.beat', + 'celery.backends', + 'celery.schedules', + 'celery.result', + 'celery.signals', + 'celery.utils', + 'celery.worker.direct', + 'celery.worker.kafka', + 'celery.worker.amqp', + 'celery.worker.redis', + 'celery.worker.database', + 'celery.worker.mongodb', + 'celery.worker.sqlalchemy', + 'celery.worker.rabbitmq', + 'celery.worker.celery', + 'celery.app.control', + 'celery.events.state', + 'celery.app.events', + 'celery.app.base', + 'celery.app.registry', + 'celery.app.trace', + 'celery.app.utils', + 'celery.worker.control', + 'celery.events', + 'celery.configuration', + 'celery.config', + 'celery.beat.schedulers', + 'celery.security', + 'celery.serialization', + 'celery.backends.base', + 'celery.backends.cache', + 'celery.backends.database', + 'celery.backends.redis', + 'celery.backends.rpc', + 'celery.backends.mongodb', + 'celery.backends.couchbase', + 'celery.backends.sqlalchemy', + 'celery.task', + 'celery.task.base', + 'celery.task.control', + 'celery.task.coordinator', + 'celery.task.state', + 'celery.task.tasks', + 'celery.debug', + 'celery.monitoring', + 'channels_redis', + 'channels_redis.core', + 'urls', + 'shikshalokam_mohini.asgi', + 'shikshalolam_mohini.urls', + 'celery.bin.worker', + 'celery.app.amqp', + 'kombu.transport.pyamqp', + 'celery.worker.components' +] + +a = Analysis( + ['/home/ubuntu/shikshalokam-mohini-service/shikshalokam-mohini-service/manage.py'], + pathex=['/home/ubuntu/shikshalokam-mohini-service/shikshalokam-mohini-service'], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='manage', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..291d0bd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,68 @@ +site_name: Shikshalokam Backend Documentation + +theme: + name: material + features: + - navigation.sections + - navigation.expand + - navigation.instant + - navigation.top + - search.highlight + - content.code.copy + - toc.follow + - toc.integrate + +nav: + - Home: index.md + + - Getting Started: + - Developer Setup: setup/developer_setup.md + - Applications: + - Chatbot: + - Overview: apps/chatbot/overview.md + - Authentication: apps/chatbot/chatbot_auth.md + - Admin: apps/chatbot/chatbot_admin.md + - Form: apps/chatbot/chatbot_form.md + - Services: apps/chatbot/chatbot_services.md + - Strategies: apps/chatbot/chatbot_strategies.md + - Consumers: apps/chatbot/chatbot_consumers.md + - Celery Tasks: apps/chatbot/chatbot_celery_tasks.md + - Utils: apps/chatbot/chatbot_utils.md + - Management Commands: apps/chatbot/chatbot_management.md + - URLs and Routing: apps/chatbot/chatbot_urls.md + - Templates: apps/chatbot/chatbot_templates.md + - Serializer and Filters: apps/chatbot/chatbot_serializer_and_filter.md + - PDF Generation: apps/chatbot/chatbot_pdf.md + - Scripts: apps/chatbot/chatbot_scripts.md + - Translation Layer: apps/chatbot/chatbot_translate.md + - Models: apps/chatbot/models.md + - Views: apps/chatbot/views.md + - Shikshalokam: + - Overview: apps/shikshalokam/overview.md + - Utils: apps/shikshalokam/utils.md + - Views: apps/shikshalokam/views.md + - Admin: apps/shikshalokam/admin.md + - URLs: apps/shikshalokam/urls.md + - Models: apps/shikshalokam/models.md + - Observability: + - Overview: apps/observability/overview.md + - Admin: apps/observability/observability_admin.md + - Celery Tasks: apps/observability/observability_celery_tasks.md + - Utils: apps/observability/observability_utils.md + - Views: apps/observability/observability_views.md + - URLs: apps/observability/observability_urls.md + - Models: apps/observability/models.md + + + - Backend: + - Translation Layer: backend/translate.md + - Large Language Model: backend/llm.md + - Enums: backend/enums.md + + - Integrations: + - Vector DB: + - Qdrant: + - API Documentation: integrations/vector_db/qdrant/api_documentation.md + - Developer Guide: integrations/vector_db/qdrant/developer_guide.md + - System Architecture: integrations/vector_db/qdrant/system_architecture.md + - Testing Guide: integrations/vector_db/qdrant/testing_guide.md diff --git a/observability/.DS_Store b/observability/.DS_Store new file mode 100644 index 0000000..2713814 Binary files /dev/null and b/observability/.DS_Store differ diff --git a/observability/__init__.py b/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/observability/admin/__init__.py b/observability/admin/__init__.py new file mode 100644 index 0000000..abe9445 --- /dev/null +++ b/observability/admin/__init__.py @@ -0,0 +1,9 @@ +from observability.admin.company_bot_test_cases_admin import CompanyBotTestCasesAdmin +from observability.admin.company_bot_tc_run_admin import CompanyBotTCRunAdmin +from observability.admin.bot_run_test_case_map_admin import CompanyBotRunTestCaseMapAdmin + +__all__ = [ + 'CompanyBotTestCasesAdmin', + 'CompanyBotTCRunAdmin', + 'CompanyBotRunTestCaseMapAdmin', +] \ No newline at end of file diff --git a/observability/admin/bot_run_test_case_map_admin.py b/observability/admin/bot_run_test_case_map_admin.py new file mode 100644 index 0000000..1006d81 --- /dev/null +++ b/observability/admin/bot_run_test_case_map_admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from observability.models import BotRunTestCaseMap + + +@admin.register(BotRunTestCaseMap) +class CompanyBotRunTestCaseMapAdmin(admin.ModelAdmin): + list_display = ('bot_run', 'metric_name', 'test_case', 'status', 'created_at') + raw_id_fields = ('bot_run', 'test_case', ) + + list_filter = ( + 'status', + 'metric_name', + ('bot_run', admin.RelatedFieldListFilter), + ('test_case', admin.RelatedFieldListFilter), + CustomAdvanceDateFilter, + ) + + search_fields = ( + 'metric_name', + 'status', + 'bot_run__id', + 'test_case__about', + 'response_log', + ) + + date_hierarchy = 'created_at' + ordering = ('-created_at',) \ No newline at end of file diff --git a/observability/admin/company_bot_tc_run_admin.py b/observability/admin/company_bot_tc_run_admin.py new file mode 100644 index 0000000..d69a4c5 --- /dev/null +++ b/observability/admin/company_bot_tc_run_admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from observability.models import CompanyBotTCRun + + +@admin.register(CompanyBotTCRun) +class CompanyBotTCRunAdmin(admin.ModelAdmin): + readonly_fields = ['status', 'metrics_result'] + raw_id_fields = ('company_bot', ) + list_display = ('company_bot', 'status', 'created_at') + + list_filter = ( + 'status', + ('company_bot', admin.RelatedFieldListFilter), + CustomAdvanceDateFilter, + ) + + search_fields = ('company_bot__name', 'status') + date_hierarchy = 'created_at' + ordering = ('-created_at',) \ No newline at end of file diff --git a/observability/admin/company_bot_test_cases_admin.py b/observability/admin/company_bot_test_cases_admin.py new file mode 100644 index 0000000..69cb4be --- /dev/null +++ b/observability/admin/company_bot_test_cases_admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from observability.models import CompanyBotTestCases, TCBotRunMetrics + + +class TCBotRunMetricsAdmin(admin.TabularInline): + model = TCBotRunMetrics + extra = 1 + + +@admin.register(CompanyBotTestCases) +class CompanyBotTestCasesAdmin(admin.ModelAdmin): + list_display = ('company_bot', 'about', 'created_at') + raw_id_fields = ('company_bot', ) + + list_filter = ( + ('company_bot', admin.RelatedFieldListFilter), + CustomAdvanceDateFilter, + ) + + # Adds search capability + search_fields = ('about', 'company_bot__name', 'test_case_input', 'expected_output') + + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + # This method is called when the admin change form is rendered. + if object_id: + self.inlines = [TCBotRunMetricsAdmin] + else: + self.inlines = [] + return super().changeform_view(request, object_id, form_url, extra_context) \ No newline at end of file diff --git a/observability/apps.py b/observability/apps.py new file mode 100644 index 0000000..a6d094e --- /dev/null +++ b/observability/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ObservabilityConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'observability' diff --git a/observability/celery_tasks/__init__.py b/observability/celery_tasks/__init__.py new file mode 100644 index 0000000..002c9de --- /dev/null +++ b/observability/celery_tasks/__init__.py @@ -0,0 +1 @@ +from .llm_test_cases import * diff --git a/observability/celery_tasks/llm_test_cases.py b/observability/celery_tasks/llm_test_cases.py new file mode 100644 index 0000000..7bce29c --- /dev/null +++ b/observability/celery_tasks/llm_test_cases.py @@ -0,0 +1,311 @@ +import traceback +from celery import shared_task + +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.services.core.prompt_builder import PromptBuilder +from chatbot.utils.llm import LLM +from observability.utils.preparechats import get_chat_dict +from observability.models.enums import TestCaseInputFormat, TCRunMetrics, TCStatus +from chatbot.models import CompanyBot, LLMProvider, CompanyChat, CompanyBotTypeChoices, CompanyStateMachine +from chatbot.utils.env_parser import load_env_to_dict +from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric, ContextualPrecisionMetric, ContextualRecallMetric, ContextualRelevancyMetric, BiasMetric, ToxicityMetric, SummarizationMetric, PromptAlignmentMetric, HallucinationMetric, GEval +from observability.utils.deepeval import DeepEvalBaseLLM +from deepeval.test_case import LLMTestCase, ConversationalTestCase, Turn +import json +from django.db.models import Avg +from chatbot.utils.chat_utils import get_guided_chat + + +def execute_test_case( + test_case, + company_bot: CompanyBot, + deepeval_llm_model: str, + deepeval_llm_provider: str, + tc_run_id: int, +): + + model = company_bot.llm_model + provider = company_bot.provider + temperature = company_bot.bot_temperature + provider_keys = company_bot.provider_keys + system_prompt = company_bot.context + + # dynamic imports due to circular dependencies + from observability.models import CompanyBotTCRun, TCBotRunMetrics, BotRunTestCaseMap + + # if provider == LLMProvider.BEDROCK_CONVERSE: + # response = handle_bedrock_model(company_bot, system_prompt) + + + metrics_val = {} + deepeval_model_name = deepeval_llm_model + + if deepeval_llm_provider != LLMProvider.OPENAI: + deepeval_model_name = deepeval_llm_provider + "/" + deepeval_llm_model + + deepeval_model = DeepEvalBaseLLM(model=deepeval_model_name) + + metrics_threshold = {} + + eval_metrics = list(TCBotRunMetrics.objects.filter( + bot_tc_run=test_case.pk).all()) + for metric in eval_metrics: + metrics_threshold[metric.metric_name] = metric.metric_threshold_value + metric_args = { + "threshold": metric.metric_threshold_value, + "model": deepeval_model, + "include_reason": True + } + + if metric.metric_name == TCRunMetrics.GEVAL: + metrics_val[metric.metric_name] = GEval( + **metric_args) + + if metric.metric_name == TCRunMetrics.ANSWER_RELEVANCY: + metrics_val[metric.metric_name] = AnswerRelevancyMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.FAITHFULLNESS: + metrics_val[metric.metric_name] = FaithfulnessMetric(**metric_args) + + elif metric.metric_name == TCRunMetrics.CONTEXTUAL_PRECISION: + metrics_val[metric.metric_name] = ContextualPrecisionMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.CONTEXTUAL_RECALL: + metrics_val[metric.metric_name] = ContextualRecallMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.CONTEXTUAL_RELEVANCY: + metrics_val[metric.metric_name] = ContextualRelevancyMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.BIAS: + metrics_val[metric.metric_name] = BiasMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.TOXICITY: + metrics_val[metric.metric_name] = ToxicityMetric( + **metric_args) + + elif metric.metric_name == TCRunMetrics.SUMMARIZATION: + metrics_val[metric.metric_name] = SummarizationMetric( + **metric_args, assessment_questions=metric.assessment_questions) + + elif metric.metric_name == TCRunMetrics.PROMPT_ALLIGNMENT: + metrics_val[metric.metric_name] = PromptAlignmentMetric( + model=deepeval_model, + prompt_instructions=metric.prompt_instructions, + threshold=metric.metric_threshold_value + ) + + elif metric.metric_name == TCRunMetrics.HALLUCINATION: + metrics_val[metric.metric_name] = HallucinationMetric( + **metric_args) + + # need to do: Add JSON Relevency metric as well + else: + pass + + llm = LLM( + model=model, + provider=provider, + temperature=temperature, + llm_env_conf=load_env_to_dict(provider_keys) + ) + + testcase_input = test_case.testcase_input + test_case_message = [] + response_log = { + "input": testcase_input, + "expected_output": test_case.expected_output, + "retrieval_context": test_case.retrieval_context, + "system_prompt": system_prompt, + "messages": None, + "actual_output": None, + "errors": [], + "metric_results": [] + } + company_chats = None + if test_case.chat_session: + company_chats = CompanyChat.objects.select_related('sender', 'receiver').filter(session=test_case.chat_session.session).order_by('created_at').values("receiver", "receiver__id", "translated_message", "message", "status", "created_at") + + if company_bot.bot_type == CompanyBotTypeChoices.STATE_MACHINE: + state_machine = CompanyStateMachine.objects.get(company_bot=company_bot, step=test_case.chat_session.current_step) + system_prompt = PromptBuilder.build_system_prompt(company_bot, state_machine) + response_log["system_prompt"] = system_prompt + + if test_case.input_format == TestCaseInputFormat.JSON: + try: + if test_case.chat_session is not None: + test_case_message = get_guided_chat(company_bot, company_chats) + + else: + test_case_message = json.loads(test_case.message) + + except Exception as e: + error_msg = f"[JSON Parse Error] TestCase {test_case.pk}: {str(e)}" + print(error_msg) + response_log["errors"].append(error_msg) + pass + + actual_output = None + try: + if provider == LLMProvider.BEDROCK_CONVERSE: + actual_output = handle_bedrock_model( + company_bot=company_bot, + system_prompt=system_prompt if isinstance(system_prompt, list) else [{ "text": system_prompt }], + messages=test_case_message, + max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, + model_name=company_bot.llm_model + ) + response_log["actual_output"] = actual_output + print("Actual Output: ", actual_output) + except Exception as e: + traceback.print_exc() + error_msg = f"[LLM Prompt Error] TestCase {test_case.pk}: {str(e)}" + response_log["errors"].append(error_msg) + try: + if test_case.input_format == TestCaseInputFormat.JSON: + test_case_llm = LLMTestCase( + input=json.dumps(test_case_message), + actual_output=json.dumps(actual_output) if type(actual_output) is dict else actual_output, + expected_output=test_case.expected_output, + retrieval_context=test_case.retrieval_context.split("\n"), + ) + else: + test_case_llm = LLMTestCase( + input=testcase_input, + actual_output=json.dumps(actual_output) if type(actual_output) is dict else actual_output, + expected_output=test_case.expected_output, + retrieval_context=test_case.retrieval_context.split("\n"), + ) + for metric in metrics_val: + try: + print("Running metric evaluation for --> " + metric) + metrics_val[metric].measure(test_case_llm) + print(metrics_val[metric].reason, "<< REASON") + print(metrics_val[metric].score, "<< SCORE") + print(metrics_val[metric]) + + response_log["metric_results"].append({ + "metric_name": metric, + "score": metrics_val[metric].score, + "reason": metrics_val[metric].reason, + "status": "PASS" if metrics_val[metric].is_successful() else "FAILED" + }) + + run_tc_map = BotRunTestCaseMap( + bot_run=CompanyBotTCRun(pk=tc_run_id), + test_case=test_case, + metric_name=metric, + score=metrics_val[metric].score, + reason=metrics_val[metric].reason, + response_log = json.dumps(response_log, indent=2), + status=TCStatus.PASS if metrics_val[metric].is_successful( + ) else TCStatus.FAILED + ) + run_tc_map.save() + except Exception as metric_err: + error_msg = f"[Metric Eval Error] Metric {metric}, TestCase {test_case.pk}: {str(metric_err)}" + print(error_msg) + response_log["errors"].append(error_msg) + + print(list(metrics_val.keys())) + + print("Eval Metrics printed: ", eval_metrics, len(eval_metrics)) + except Exception as eval_err: + traceback.print_exc() + error_msg = f"[Eval Init Error] TestCase {test_case.pk}: {str(eval_err)}" + print(error_msg) + response_log["errors"].append(error_msg) + finally: + try: + existing_metrics = set( + BotRunTestCaseMap.objects.filter( + bot_run_id=tc_run_id, + test_case=test_case + ).values_list('metric_name', flat=True) + ) + + for metric in metrics_val: + if metric not in existing_metrics: + BotRunTestCaseMap.objects.create( + bot_run=CompanyBotTCRun(pk=tc_run_id), + test_case=test_case, + metric_name=metric, + score=0, + reason="Execution failed. See response_log for errors.", + response_log=json.dumps(response_log, indent=2), + status=TCStatus.FAILED + ) + + if not metrics_val and not existing_metrics: + print("No metric found.") + except Exception as final_save_err: + print(f"[Final Save Error] TestCase {test_case.pk}: {str(final_save_err)}") + + +@shared_task +def run(company_bot_id: int, tc_run_id: int): + # dynamic imports due to circular dependencies + from observability.models import CompanyBotTestCases, CompanyBotTCRun, TCBotRunMetrics, BotRunTestCaseMap + from observability.models.enums import TCRunStatus + + company_bot = CompanyBot.objects.get(pk=company_bot_id) + bot_tc_run = CompanyBotTCRun.objects.get(pk=tc_run_id) + company_test_cases = list(CompanyBotTestCases.objects.filter( + company_bot=CompanyBot(pk=company_bot_id) + ).all()) + + print(company_test_cases) + # loads the test cases + try: + for test_case in company_test_cases: + try: + print("Running Test for: ", test_case) + execute_test_case( + test_case, + company_bot= company_bot, + tc_run_id=tc_run_id, + deepeval_llm_model=bot_tc_run.llm_model, + deepeval_llm_provider=bot_tc_run.provider, + ) + except Exception as e: + print(f"[TC Run Error] TestCase {test_case.pk}: {str(e)}") + traceback.print_exc() + continue + + # NOTE: + # solution + # DONE: deepeval llm terminologies + # DONE: threshold to be exposed to db + # DONE: test cases metrics should be customizable + # DONE: metric scores to be stored in the db + # DONE: metric should be at test case level + # DONE: chat session to be added to TC Run Test Case (prepare messages for chat sessions) + # DONE: test case pass fail status + # need to do: Verifier and checker company bot and testing + # need to do: Langfuse integration with observability + # need to do: if test cases fail how to improve it?? + + results = BotRunTestCaseMap.objects.filter( + bot_run=CompanyBotTCRun(pk=tc_run_id) + ).values('metric_name').annotate(avg_score=Avg('score')) + + tc_avg_results = {} + + for res in results: + print("Final Results...") + print(res["avg_score"], res["metric_name"]) + tc_avg_results[res["metric_name"]] = res["avg_score"] + + bot_tc_run.status = TCRunStatus.COMPLETED + bot_tc_run.metrics_result = json.dumps(tc_avg_results) + bot_tc_run.save() + except Exception as e: + print("Error: ", e) + traceback.print_exc() + bot_tc_run.status = TCRunStatus.FAILED + bot_tc_run.save() diff --git a/observability/migrations/0001_initial.py b/observability/migrations/0001_initial.py new file mode 100644 index 0000000..f924d08 --- /dev/null +++ b/observability/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.2 on 2025-02-21 03:29 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('chatbot', '0022_botvernacular_alt_introductory_message_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CompanyBotTCRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('llm_model', models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100)), + ('provider', models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='openai', max_length=100)), + ('status', models.CharField(choices=[('running', 'RUNNING'), ('completed', 'COMPLETED'), ('failed', 'FAILED')], default='running', max_length=100)), + ('metrics_result', models.TextField(blank=True, null=True)), + ('company_bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chatbot.companybot')), + ], + ), + migrations.CreateModel( + name='CompanyBotTestCases', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('testcase_input', models.TextField(blank=True, null=True)), + ('expected_output', models.TextField()), + ('message', models.TextField(blank=True, null=True)), + ('retrieval_context', models.TextField(blank=True, null=True)), + ('input_format', models.CharField(choices=[('json', 'JSON'), ('text', 'TEXT')], default='json', max_length=100)), + ('json_output_schema', models.TextField(blank=True, null=True)), + ('chat_session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chatbot.chatsession')), + ('company_bot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chatbot.companybot')), + ], + ), + migrations.CreateModel( + name='BotRunTestCaseMap', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metric_name', models.CharField(choices=[('answer_relevancy', 'ANSWER_RELEVANCY'), ('faithfullness', 'FAITHFULLNESS'), ('contextual_precision', 'CONTEXTUAL_PRECISION'), ('contextual_recall', 'CONTEXTUAL_RECALL'), ('contextual_relevancy', 'CONTEXTUAL_RELEVANCY'), ('bias', 'BIAS'), ('toxicity', 'TOXICITY'), ('summarization', 'SUMMARIZATION'), ('prompt_allignment', 'PROMPT_ALLIGNMENT'), ('hallucination', 'HALLUCINATION'), ('json_correctness', 'JSON_CORRECTNESS')], max_length=100)), + ('score', models.FloatField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(0)])), + ('reason', models.TextField(blank=True, null=True)), + ('status', models.CharField(blank=True, choices=[('pass', 'PASS'), ('failed', 'FAILED')], max_length=100, null=True)), + ('bot_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='observability.companybottcrun')), + ('test_case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='observability.companybottestcases')), + ], + ), + migrations.CreateModel( + name='TCBotRunMetrics', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metric_name', models.CharField(choices=[('answer_relevancy', 'ANSWER_RELEVANCY'), ('faithfullness', 'FAITHFULLNESS'), ('contextual_precision', 'CONTEXTUAL_PRECISION'), ('contextual_recall', 'CONTEXTUAL_RECALL'), ('contextual_relevancy', 'CONTEXTUAL_RELEVANCY'), ('bias', 'BIAS'), ('toxicity', 'TOXICITY'), ('summarization', 'SUMMARIZATION'), ('prompt_allignment', 'PROMPT_ALLIGNMENT'), ('hallucination', 'HALLUCINATION'), ('json_correctness', 'JSON_CORRECTNESS')], max_length=100)), + ('assessment_questions', models.TextField(blank=True, null=True)), + ('metric_threshold_value', models.FloatField(default=0.7, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(0)])), + ('metric_score', models.FloatField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(0)])), + ('reason', models.TextField(blank=True, null=True)), + ('bot_tc_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='observability.companybottestcases')), + ], + ), + ] diff --git a/observability/migrations/0002_historicalbotruntestcasemap_and_more.py b/observability/migrations/0002_historicalbotruntestcasemap_and_more.py new file mode 100644 index 0000000..dd0d09f --- /dev/null +++ b/observability/migrations/0002_historicalbotruntestcasemap_and_more.py @@ -0,0 +1,170 @@ +# Generated by Django 5.1.2 on 2025-02-26 10:01 + +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0023_companybot_provider_companybot_provider_keys_and_more'), + ('observability', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalBotRunTestCaseMap', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('metric_name', models.CharField(choices=[('answer_relevancy', 'ANSWER_RELEVANCY'), ('faithfullness', 'FAITHFULLNESS'), ('contextual_precision', 'CONTEXTUAL_PRECISION'), ('contextual_recall', 'CONTEXTUAL_RECALL'), ('contextual_relevancy', 'CONTEXTUAL_RELEVANCY'), ('bias', 'BIAS'), ('toxicity', 'TOXICITY'), ('summarization', 'SUMMARIZATION'), ('prompt_allignment', 'PROMPT_ALLIGNMENT'), ('hallucination', 'HALLUCINATION'), ('json_correctness', 'JSON_CORRECTNESS')], max_length=100)), + ('score', models.FloatField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(0)])), + ('reason', models.TextField(blank=True, null=True)), + ('status', models.CharField(blank=True, choices=[('pass', 'PASS'), ('failed', 'FAILED')], max_length=100, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical bot run test case map', + 'verbose_name_plural': 'historical bot run test case maps', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalCompanyBotTCRun', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('llm_model', models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100)), + ('provider', models.CharField(choices=[('bedrock', 'BEDROCK'), ('bedrock/converse', 'BEDROCK_CONVERSE'), ('openai', 'OPENAI')], default='openai', max_length=100)), + ('status', models.CharField(choices=[('running', 'RUNNING'), ('completed', 'COMPLETED'), ('failed', 'FAILED')], default='running', max_length=100)), + ('metrics_result', models.TextField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical company bot tc run', + 'verbose_name_plural': 'historical company bot tc runs', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalCompanyBotTestCases', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('testcase_input', models.TextField(blank=True, null=True)), + ('expected_output', models.TextField()), + ('message', models.TextField(blank=True, null=True)), + ('retrieval_context', models.TextField(blank=True, null=True)), + ('input_format', models.CharField(choices=[('json', 'JSON'), ('text', 'TEXT')], default='json', max_length=100)), + ('json_output_schema', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical company bot test cases', + 'verbose_name_plural': 'historical company bot test casess', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name='botruntestcasemap', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='botruntestcasemap', + name='updated_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='companybottestcases', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='companybottestcases', + name='updated_at', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddIndex( + model_name='botruntestcasemap', + index=models.Index(fields=['bot_run'], name='observabili_bot_run_4cf0f4_idx'), + ), + migrations.AddIndex( + model_name='botruntestcasemap', + index=models.Index(fields=['metric_name'], name='observabili_metric__8cb07c_idx'), + ), + migrations.AddIndex( + model_name='companybottestcases', + index=models.Index(fields=['company_bot'], name='observabili_company_8afb8f_idx'), + ), + migrations.AddIndex( + model_name='companybottestcases', + index=models.Index(fields=['created_at'], name='observabili_created_7c2e52_idx'), + ), + migrations.AddIndex( + model_name='companybottestcases', + index=models.Index(fields=['chat_session'], name='observabili_chat_se_d06d4c_idx'), + ), + migrations.AddField( + model_name='historicalbotruntestcasemap', + name='bot_run', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='observability.companybottcrun'), + ), + migrations.AddField( + model_name='historicalbotruntestcasemap', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalbotruntestcasemap', + name='test_case', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='observability.companybottestcases'), + ), + migrations.AddField( + model_name='historicalcompanybottcrun', + name='company_bot', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='historicalcompanybottcrun', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalcompanybottestcases', + name='chat_session', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.chatsession'), + ), + migrations.AddField( + model_name='historicalcompanybottestcases', + name='company_bot', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.companybot'), + ), + migrations.AddField( + model_name='historicalcompanybottestcases', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/observability/migrations/0003_companybottestcases_about_and_more.py b/observability/migrations/0003_companybottestcases_about_and_more.py new file mode 100644 index 0000000..a0ffaf6 --- /dev/null +++ b/observability/migrations/0003_companybottestcases_about_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-04-07 14:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observability', '0002_historicalbotruntestcasemap_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='companybottestcases', + name='about', + field=models.TextField(blank=True, help_text='Optional description of the test case. For informational purposes only; it does not affect the test output.', null=True), + ), + migrations.AddField( + model_name='historicalcompanybottestcases', + name='about', + field=models.TextField(blank=True, help_text='Optional description of the test case. For informational purposes only; it does not affect the test output.', null=True), + ), + ] diff --git a/observability/migrations/0004_botruntestcasemap_response_log_and_more.py b/observability/migrations/0004_botruntestcasemap_response_log_and_more.py new file mode 100644 index 0000000..f067dd0 --- /dev/null +++ b/observability/migrations/0004_botruntestcasemap_response_log_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-04-12 09:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observability', '0003_companybottestcases_about_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='botruntestcasemap', + name='response_log', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalbotruntestcasemap', + name='response_log', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/observability/migrations/0005_alter_companybottcrun_llm_model_and_more.py b/observability/migrations/0005_alter_companybottcrun_llm_model_and_more.py new file mode 100644 index 0000000..e7e7ae3 --- /dev/null +++ b/observability/migrations/0005_alter_companybottcrun_llm_model_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-10-31 09:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observability', '0004_botruntestcasemap_response_log_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='companybottcrun', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybottcrun', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct')], default='gpt-4o-mini', max_length=100), + ), + ] diff --git a/observability/migrations/0006_alter_companybottcrun_llm_model_and_more.py b/observability/migrations/0006_alter_companybottcrun_llm_model_and_more.py new file mode 100644 index 0000000..802231e --- /dev/null +++ b/observability/migrations/0006_alter_companybottcrun_llm_model_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2026-03-09 03:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observability', '0005_alter_companybottcrun_llm_model_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='companybottcrun', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct'), ('gpt-5.2', 'GPT_5_2'), ('gpt-5.2-pro', 'GPT_5_2_PRO'), ('gpt-5-mini', 'GPT_5_MINI')], default='gpt-4o-mini', max_length=100), + ), + migrations.AlterField( + model_name='historicalcompanybottcrun', + name='llm_model', + field=models.CharField(choices=[('gpt-4', 'GPT4'), ('gpt-4.1', 'GPT4_1'), ('gpt-4.1-mini', 'GPT4_1-MINI'), ('gpt-4-1106-preview', 'GPT4-128k'), ('gpt-4-turbo', 'GPT4_TURBO'), ('llama3-8b-8192', 'LLAMA_3_8B_8192'), ('llama3-70b-8192', 'LLAMA_3_70B_8192'), ('llama-3.1-70b-versatile', 'LLAMA_3_1_70B_VERSATILE'), ('llama-3.1-8b-instant', 'LLAMA_3_1_8B_INSTANT'), ('meta.llama3-1-70b-instruct-v1:0', 'LLAMA_3_1_70B_INSTRUCT'), ('meta.llama3-1-8b-instruct-v1:0', 'LLAMA_3_1_8B_INSTRUCT'), ('us.meta.llama3-3-70b-instruct-v1:0', 'LLAMA_3_3_70B_INSTRUCT'), ('us.meta.llama3-3-8b-instruct-v1:0', 'LLAMA_3_3_8B_INSTRUCT'), ('mixtral-8x7b-32768', 'MIXTRAL_8X70B_32768'), ('gpt-4o', 'GPT4_O'), ('gpt-4o-mini', 'GPT4_O_MINI'), ('meta-llama/Meta-Llama-3.1-8B-Instruct', 'meta-llama/Meta-Llama-3.1-8B-Instruct'), ('gpt-5.2', 'GPT_5_2'), ('gpt-5.2-pro', 'GPT_5_2_PRO'), ('gpt-5-mini', 'GPT_5_MINI')], default='gpt-4o-mini', max_length=100), + ), + ] diff --git a/observability/migrations/__init__.py b/observability/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/observability/models/__init__.py b/observability/models/__init__.py new file mode 100644 index 0000000..7c3c214 --- /dev/null +++ b/observability/models/__init__.py @@ -0,0 +1,2 @@ +from .base_models import * +from .enums import * diff --git a/observability/models/base_models.py b/observability/models/base_models.py new file mode 100644 index 0000000..ef41ca1 --- /dev/null +++ b/observability/models/base_models.py @@ -0,0 +1,114 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from .enums import TestCaseInputFormat, TCRunStatus, TCRunMetrics, TCStatus +from django.db import models +from chatbot.models import CompanyBot, LLMModel, LLMProvider, ChatSession +from observability.celery_tasks import llm_test_cases +from django.db.models.signals import post_save +from django.dispatch import receiver +from simple_history.models import HistoricalRecords + + +class CompanyBotTestCases(models.Model): + about = models.TextField( + null=True, + blank=True, + help_text="Optional description of the test case. For informational purposes only; it does not affect the test output." + ) + company_bot = models.ForeignKey( + CompanyBot, on_delete=models.CASCADE, null=True, blank=True) + testcase_input = models.TextField(blank=True, null=True) + expected_output = models.TextField(blank=False, null=False) + chat_session = models.ForeignKey( + ChatSession, blank=True, null=True, on_delete=models.CASCADE) + message = models.TextField(blank=True, null=True) + retrieval_context = models.TextField(blank=True, null=True) + input_format = models.CharField( + max_length=100, choices=TestCaseInputFormat, default=TestCaseInputFormat.JSON) + + json_output_schema = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + history = HistoricalRecords() + + class Meta: + indexes = [ + models.Index(fields=['company_bot']), + models.Index(fields=['created_at']), + models.Index(fields=['chat_session']), + ] + + +class CompanyBotTCRun(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + company_bot = models.ForeignKey(CompanyBot, on_delete=models.CASCADE) + llm_model = models.CharField( + max_length=100, choices=LLMModel.choices, default=LLMModel.GPT4_O_MINI) + provider = models.CharField( + max_length=100, choices=LLMProvider.choices, default=LLMProvider.OPENAI) + status = models.CharField( + max_length=100, choices=TCRunStatus.choices, default=TCRunStatus.RUNNING) + metrics_result = models.TextField(null=True, blank=True) + history = HistoricalRecords() + + def __str__(self): + return self.company_bot.name + " (" + self.llm_model + ")" + + +class TCBotRunMetrics(models.Model): + bot_tc_run = models.ForeignKey( + CompanyBotTestCases, on_delete=models.CASCADE, null=False, blank=False + ) + metric_name = models.CharField( + max_length=100, null=False, blank=False, choices=TCRunMetrics.choices) + prompt_instructions = models.TextField(null=True, blank=True), + assessment_questions = models.TextField( + blank=True, null=True) + metric_threshold_value = models.FloatField(null=False, default=0.7, validators=[ + MaxValueValidator(1), + MinValueValidator(0) + ]) + metric_score = models.FloatField(null=True, blank=True, validators=[ + MaxValueValidator(1), + MinValueValidator(0) + ]) + reason = models.TextField(null=True, blank=True) + + def __str__(self): + return self.metric_name + + +class BotRunTestCaseMap(models.Model): + bot_run = models.ForeignKey(CompanyBotTCRun, on_delete=models.CASCADE) + test_case = models.ForeignKey( + CompanyBotTestCases, on_delete=models.CASCADE) + metric_name = models.CharField( + max_length=100, null=False, blank=False, choices=TCRunMetrics.choices) + score = models.FloatField(null=True, blank=True, validators=[ + MaxValueValidator(1), + MinValueValidator(0) + ]) + reason = models.TextField(null=True, blank=True) + status = models.CharField( + max_length=100, choices=TCStatus.choices, null=True, blank=True + ) + response_log = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + history = HistoricalRecords() + + class Meta: + indexes = [ + models.Index(fields=['bot_run']), + models.Index(fields=['metric_name']), + ] + + def __str__(self): + return self.metric_name + + +@receiver(post_save, sender=CompanyBotTCRun) +def run_test_cases(sender, instance, created, **kwargs): + if created: + print("created tc run", created, instance.pk) + llm_test_cases.run.delay(instance.company_bot.pk, instance.pk) diff --git a/observability/models/enums.py b/observability/models/enums.py new file mode 100644 index 0000000..9c8b585 --- /dev/null +++ b/observability/models/enums.py @@ -0,0 +1,33 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TestCaseInputFormat(models.TextChoices): + JSON = 'json', _('JSON') + TEXT = 'text', _('TEXT') + + +class TCRunStatus(models.TextChoices): + RUNNING = 'running', _('RUNNING') + COMPLETED = 'completed', _('COMPLETED') + FAILED = 'failed', _('FAILED') + + +class TCStatus(models.TextChoices): + PASS = 'pass', _('PASS') + FAILED = 'failed', _('FAILED') + + +class TCRunMetrics(models.TextChoices): + ANSWER_RELEVANCY = 'answer_relevancy', _('ANSWER_RELEVANCY') + FAITHFULLNESS = 'faithfullness', _('FAITHFULLNESS') + CONTEXTUAL_PRECISION = 'contextual_precision', _('CONTEXTUAL_PRECISION') + CONTEXTUAL_RECALL = 'contextual_recall', _('CONTEXTUAL_RECALL') + CONTEXTUAL_RELEVANCY = 'contextual_relevancy', _('CONTEXTUAL_RELEVANCY') + BIAS = 'bias', _('BIAS') + TOXICITY = 'toxicity', _('TOXICITY') + SUMMARIZATION = 'summarization', _('SUMMARIZATION') + PROMPT_ALLIGNMENT = 'prompt_allignment', _('PROMPT_ALLIGNMENT') + HALLUCINATION = 'hallucination', _('HALLUCINATION') + JSON_CORRECTNESS = 'json_correctness', _('JSON_CORRECTNESS') + GEVAL = 'geval', _('GEVAL') diff --git a/observability/tests.py b/observability/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/observability/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/observability/urls.py b/observability/urls.py new file mode 100644 index 0000000..596a4d4 --- /dev/null +++ b/observability/urls.py @@ -0,0 +1,24 @@ +""" +URL configuration for gritworks project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.urls import path +from debug_toolbar.toolbar import debug_toolbar_urls +from .views import test_prompt_view + + +urlpatterns = [ + path('test_bot_prompt/', test_prompt_view, name="observability_prompt") +] + debug_toolbar_urls() diff --git a/observability/utils/deepeval.py b/observability/utils/deepeval.py new file mode 100644 index 0000000..070b78a --- /dev/null +++ b/observability/utils/deepeval.py @@ -0,0 +1,47 @@ +from litellm import acompletion, completion +from deepeval.models.base_model import DeepEvalBaseLLM +from pydantic import BaseModel +import instructor + + +class DeepEvalBaseLLM(DeepEvalBaseLLM): + + def __init__( + self, + model: str + ): + self.model = model + self.client = instructor.from_litellm(completion) + + def load_model(self): + return self.model + + def generate(self, prompt: str, schema: BaseModel) -> BaseModel: + messages = [{"content": prompt, "role": "user"}] + try: + response = self.client.chat.completions.create( + model=self.model, messages=messages, response_model=schema + ) + except Exception as e: + print(e) + response = schema.model_construct() + + return response + + async def a_generate(self, prompt: str, schema: BaseModel) -> BaseModel: + client = instructor.from_litellm(acompletion) + + messages = [{"content": prompt, "role": "user"}] + try: + response = await client.chat.completions.create( + model=self.model, messages=messages, response_model=schema + ) + + except Exception as e: + print(e) + response = schema.model_construct() + + return response + + def get_model_name(self): + return "LiteLLM Model (" + str(self.model) + ")" diff --git a/observability/utils/preparechats.py b/observability/utils/preparechats.py new file mode 100644 index 0000000..b4312d3 --- /dev/null +++ b/observability/utils/preparechats.py @@ -0,0 +1,24 @@ +from chatbot.models import CompanyChat + + +def get_chat_dict(chat_session_id: int, exclude_end_ai_message=True): + chats = list(CompanyChat.objects.filter( + session=chat_session_id).order_by('created_at').all()) + + chat_dict = [] + + bot_profile_id = 1 # NOTE: assuiming bot id will be always 1 + + for i in range(len(chats)): + if i == len(chats) - 1 and chats[i].sender.id == bot_profile_id and exclude_end_ai_message: + continue + + if chats[i].sender.id == bot_profile_id: + chat_dict.append( + {"role": "assistant", "content": chats[i].message} + ) + + else: + chat_dict.append({"role": "user", "content": chats[i].message}) + + return chat_dict diff --git a/observability/views.py b/observability/views.py new file mode 100644 index 0000000..b50078f --- /dev/null +++ b/observability/views.py @@ -0,0 +1,22 @@ +from rest_framework.decorators import api_view +from chatbot.models import CompanyBot +from rest_framework.response import Response +from observability.models import CompanyBotTCRun +# from django.shortcuts import render + + +@api_view(['GET']) +def test_prompt_view(req, pk): + try: + test_case = CompanyBotTCRun(company_bot=CompanyBot(pk=pk)) + test_case.save() + + return Response({ + 'status': 'ok', + }, status=200) + + except Exception as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0b7a326 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,94 @@ +[project] +name = "shikshalokam-mohini-service" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "asgiref>=3.11.0", + "beautifulsoup4>=4.14.3", + "boto3>=1.42.37", + "botocore>=1.42.37", + "celery>=5.6.2", + "channels>=4.3.2", + "channels-redis>=4.3.0", + "coreapi>=2.3.3", + "coreschema>=0.0.4", + "daphne>=4.2.1", + "deepeval>=3.8.2", + "django==5.2.0", + "django-admin-rangefilter>=0.13.5", + "django-celery-results>=2.6.0", + "django-cors-headers>=4.9.0", + "django-countries>=8.2.0", + "django-crontab>=0.7.1", + "django-debug-toolbar>=6.2.0", + "django-extensions>=4.1", + "django-filter>=25.2", + "django-import-export>=4.4.0", + "django-jazzmin>=3.0.1", + "django-querycount>=0.8.3", + "django-redis>=6.0.0", + "django-s3-storage>=0.15.0", + "django-simple-history>=3.11.0", + "django-storages>=1.14.6", + "django-tailwind>=4.2.0", + "djangorestframework>=3.16.1", + "djangorestframework-simplejwt>=5.5.1", + "gevent>=26.4.0", + "google-api-python-client>=2.197.0", + "google-auth-oauthlib>=1.4.0", + "google-cloud-speech>=2.36.0", + "google-cloud-storage>=3.11.0", + "google-cloud-texttospeech>=2.34.0", + "google-cloud-translate>=3.24.0", + "import-export>=0.3.1", + "instructor>=1.14.5", + "jinja2>=3.1.6", + "json-repair>=0.55.1", + "kombu>=5.6.2", + "langfuse>=3.12.1", + "litellm>=1.81.5", + "markdown>=3.10.1", + "mkdocs>=1.6.1", + "mkdocs-material>=9.7.1", + "openai>=2.16.0", + "openpyxl>=3.1.5", + "pandas>=2.3.3", + "pdf2image>=1.17.0", + "pdfplumber>=0.11.9", + "pillow>=12.1.0", + "pillow-heif>=1.2.0", + "protobuf>=6.33.4", + "psycopg2>=2.9.11", + "pydantic>=2.12.5", + "pydantic-core>=2.41.5", + "pydantic-settings>=2.12.0", + "pydub>=0.25.1", + "pyjwt>=2.10.1", + "pypdf>=6.6.2", + "pypdf2>=3.0.1", + "pytesseract>=0.3.13", + "python-dateutil>=2.9.0.post0", + "python-docx>=1.2.0", + "python-dotenv>=1.2.1", + "pytz>=2025.2", + "qdrant-client>=1.16.2", + "rapidfuzz>=3.14.5", + "redis>=7.1.0", + "requests>=2.32.5", + "retrying>=1.4.2", + "sarvamai==0.1.26", + "scikit-learn>=1.7.2", + "sentry-sdk>=2.51.0", + "tablib>=3.9.0", + "tabulate>=0.9.0", + "tqdm>=4.67.1", + "uuid>=1.30", + "uvicorn>=0.40.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.15.0", +] diff --git a/requirement.txt b/requirement.txt new file mode 100644 index 0000000..e3ca3e1 --- /dev/null +++ b/requirement.txt @@ -0,0 +1,307 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +aiosqlite==0.22.1 +amqp==5.3.1 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +asgiref==3.11.0 +async-timeout==5.0.1 +attrs==25.4.0 +auth0-python==4.13.0 +autobahn==24.4.2 +automat==25.4.16 +backoff==2.2.1 +backports-asyncio-runner==1.2.0 +banks==2.3.0 +bcrypt==5.0.0 +beautifulsoup4==4.14.3 +billiard==4.2.4 +boto3==1.42.38 +botocore==1.42.38 +build==1.4.0 +celery==5.6.2 +certifi==2026.1.4 +cffi==2.0.0 +channels==4.3.2 +channels-redis==4.3.0 +charset-normalizer==3.4.4 +chromadb==1.4.1 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +coloredlogs==15.0.1 +constantly==23.10.4 +coreapi==2.3.3 +coreschema==0.0.4 +cryptography==46.0.4 +daphne==4.2.1 +dataclasses-json==0.6.7 +datasets==4.5.0 +deepeval==3.8.2 +defusedxml==0.7.1 +deprecated==1.2.18 +deprecation==2.1.0 +diff-match-patch==20241021 +dill==0.4.0 +dirtyjson==1.0.8 +diskcache==5.6.3 +distro==1.9.0 +django==5.2.10 +django-admin-rangefilter==0.13.5 +django-cors-headers==4.9.0 +django-countries==8.2.0 +django-crontab==0.7.1 +django-debug-toolbar==6.2.0 +django-extensions==4.1 +django-filter==25.2 +django-import-export==4.4.0 +django-jazzmin==3.0.1 +django-redis==6.0.0 +django-s3-storage==0.15.0 +django-simple-history==3.11.0 +django-storages==1.14.6 +django-tailwind==4.2.0 +djangorestframework==3.16.1 +djangorestframework-simplejwt==5.5.1 +docstring-parser==0.17.0 +docx2txt==0.9 +durationpy==0.10 +embedchain==0.0.18 +et-xmlfile==2.0.0 +exceptiongroup==1.3.1 +execnet==2.1.2 +fastapi==0.128.0 +fastuuid==0.14.0 +filelock==3.20.3 +filetype==1.2.0 +flatbuffers==25.12.19 +flower==2.0.1 +frozenlist==1.8.0 +fsspec==2025.10.0 +google-api-core==2.29.0 +google-auth==2.48.0 +google-cloud-aiplatform==1.135.0 +google-cloud-bigquery==3.40.0 +google-cloud-core==2.5.0 +google-cloud-resource-manager==1.16.0 +google-cloud-speech==2.36.0 +google-cloud-storage==3.8.0 +google-cloud-texttospeech==2.34.0 +google-cloud-translate==3.24.0 +google-crc32c==1.8.0 +google-genai==1.60.0 +google-resumable-media==2.8.0 +googleapis-common-protos==1.72.0 +gpt4all==2.8.2 +greenlet==3.3.1 +griffe==1.15.0 +grpc-google-iam-v1==0.14.3 +grpcio==1.76.0 +grpcio-status==1.76.0 +h11==0.16.0 +h2==4.3.0 +hf-xet==1.2.0 +hpack==4.1.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +huggingface-hub==1.3.5 +humanfriendly==10.0 +humanize==4.15.0 +hyperframe==6.1.0 +hyperlink==21.0.0 +idna==3.11 +importlib-metadata==8.7.1 +importlib-resources==6.5.2 +incremental==24.11.0 +iniconfig==2.3.0 +instructor==1.14.5 +itypes==1.2.0 +jinja2==3.1.6 +jiter==0.11.1 +jmespath==1.1.0 +joblib==1.5.3 +json-repair==0.55.1 +jsonpatch==1.33 +jsonpointer==3.0.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kombu==5.6.2 +kubernetes==35.0.0 +lance-namespace==0.4.5 +lance-namespace-urllib3-client==0.4.5 +lancedb==0.25.3 +langchain==1.2.7 +langchain-core==1.2.7 +langchain-openai==1.1.7 +langfuse==3.12.1 +langgraph==1.0.7 +langgraph-checkpoint==4.0.0 +langgraph-prebuilt==1.0.7 +langgraph-sdk==0.3.3 +langsmith==0.6.6 +litellm==1.81.5 +llama-cloud==0.1.35 +llama-cloud-services==0.6.54 +llama-index==0.14.13 +llama-index-cli==0.5.3 +llama-index-core==0.14.13 +llama-index-embeddings-openai==0.5.1 +llama-index-indices-managed-llama-cloud==0.9.4 +llama-index-instrumentation==0.4.2 +llama-index-llms-openai==0.6.15 +llama-index-readers-file==0.5.6 +llama-index-readers-llama-parse==0.5.1 +llama-index-workflows==2.13.1 +llama-parse==0.6.54 +lxml==6.0.2 +markdown==3.10.1 +markdown-it-py==4.0.0 +markupsafe==3.0.3 +marshmallow==3.26.2 +mdurl==0.1.2 +mmh3==5.2.0 +mpmath==1.3.0 +msgpack==1.1.2 +multidict==6.7.1 +multiprocess==0.70.18 +mypy-extensions==1.1.0 +nest-asyncio==1.6.0 +networkx==3.4.2 +nltk==3.9.2 +numpy==2.2.6 +oauthlib==3.3.1 +onnxruntime==1.23.2 +openai==2.16.0 +openpyxl==3.1.5 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +orjson==3.11.6 +ormsgpack==1.12.2 +overrides==7.7.0 +packaging==25.0 +pandas==2.3.3 +pdf2image==1.17.0 +pdfminer-six==20251230 +pdfplumber==0.11.9 +pillow==12.1.0 +pillow-heif==1.2.0 +pip==24.2 +platformdirs==4.5.1 +pluggy==1.6.0 +portalocker==3.2.0 +posthog==5.4.0 +prometheus-client==0.24.1 +prompt-toolkit==3.0.52 +propcache==0.4.1 +proto-plus==1.27.0 +protobuf==6.33.5 +psycopg2-binary==2.9.11 +pyarrow==23.0.0 +pyasn1==0.6.2 +pyasn1-modules==0.4.2 +pybase64==1.4.3 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.12.0 +pydub==0.25.1 +pyfiglet==1.0.4 +pygments==2.19.2 +pyjwt==2.10.1 +pymupdf==1.26.7 +pyopenssl==25.3.0 +pypdf==6.6.2 +pypdf2==3.0.1 +pypdfium2==5.3.0 +pypika==0.50.0 +pyproject-hooks==1.2.0 +pytesseract==0.3.13 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-repeat==0.9.4 +pytest-rerunfailures==16.1 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.2.1 +pytube==15.0.0 +pytz==2025.2 +pyyaml==6.0.3 +qdrant-client==1.16.2 +redis==7.1.0 +referencing==0.37.0 +regex==2026.1.15 +requests==2.32.5 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +retrying==1.4.2 +rich==14.3.1 +rpds-py==0.30.0 +rsa==4.9.1 +s3transfer==0.16.0 +safetensors==0.7.0 +sarvamai==0.1.22 +scikit-learn==1.7.2 +scipy==1.15.3 +sentence-transformers==5.2.2 +sentry-sdk==2.51.0 +service-identity==24.2.0 +setuptools==80.10.2 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +soupsieve==2.8.3 +sqlalchemy==2.0.46 +sqlparse==0.5.5 +starlette==0.50.0 +striprtf==0.0.26 +sympy==1.14.0 +tablib==3.9.0 +tabulate==0.9.0 +tenacity==9.1.2 +threadpoolctl==3.6.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomli==2.4.0 +torch==2.2.2 +tornado==6.5.4 +tqdm==4.67.1 +transformers==5.0.0 +twisted==25.5.0 +txaio==25.9.2 +typer==0.21.1 +typer-slim==0.21.1 +typing-extensions==4.15.0 +typing-inspect==0.9.0 +typing-inspection==0.4.2 +tzdata==2025.3 +tzlocal==5.3.1 +uritemplate==4.2.0 +urllib3==2.6.3 +uuid-utils==0.14.0 +uv==0.9.28 +uvicorn==0.40.0 +uvloop==0.22.1 +vine==5.1.0 +watchfiles==1.1.1 +wcwidth==0.5.2 +websocket-client==1.9.0 +websockets==15.0.1 +wheel==0.46.3 +wrapt==1.17.3 +xxhash==3.6.0 +yarl==1.22.0 +youtube-transcript-api==1.2.4 +zipp==3.23.0 +zope-interface==8.2 +zstandard==0.25.0 \ No newline at end of file diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..d96707d --- /dev/null +++ b/requirements.in @@ -0,0 +1,160 @@ +############################ +# Web / Backend +############################ +Django +djangorestframework +djangorestframework-simplejwt +channels +channels-redis +asgiref +daphne + +fastapi +uvicorn +uvloop +httptools +watchfiles + +celery +redis +flower + +############################ +# Django Admin / Extensions +############################ +django-admin-rangefilter +django-jazzmin +django-tailwind +django-extensions +django-cors-headers +django-filter +django-simple-history +django-crontab +django-import-export +django-countries +django-debug-toolbar + +############################ +# Auth / Security +############################ +auth0-python +cryptography +bcrypt +PyJWT + +############################ +# Storage / Cloud +############################ +boto3 +django-storages +django-s3-storage +google-cloud-storage +google-cloud-texttospeech +google-cloud-speech +google-cloud-translate +google-cloud-aiplatform +psycopg2-binary + +############################ +# AI / LLM Core +############################ +openai +instructor +litellm + +############################ +# LangChain ecosystem +############################ +langchain +langchain-openai +langgraph + +############################ +# LlamaIndex ecosystem +############################ +llama-index +llama-parse + +############################ +# Vector DB / Search +############################ +chromadb +qdrant-client +lancedb + +############################ +# Evaluation / Observability +############################ +deepeval +langfuse +posthog + +############################ +# AI utilities / Providers +############################ +embedchain +tiktoken +sarvamai + +############################ +# Data / NLP +############################ +numpy<2 +pandas +nltk +datasets +huggingface-hub + +############################ +# Files / Docs / OCR +############################ +pypdf +PyPDF2 +pdfplumber +pdf2image +PyMuPDF +pytesseract +python-docx +docx2txt +openpyxl + +############################ +# Media / Image +############################ +pillow +pillow-heif +pydub + +############################ +# General utilities +############################ +requests +httpx +orjson +rich +tqdm +tenacity +python-dotenv +jsonschema +jsonpatch +json-repair +retrying +Markdown + +############################ +# Dev / Testing +############################ +pytest +pytest-xdist +pytest-repeat + +############################ +# Django / Cache / Redis +############################ +django-redis + +############################ +# DRF / Schema utilities +############################ +coreapi +coreschema diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..ea8ce96 --- /dev/null +++ b/sample.env @@ -0,0 +1,50 @@ +DATABASE_NAME='shikshalokam_uat' +DATABASE_USER='postgres' +DATABASE_PASSWORD='' +DATABASE_HOST='' +DATABASE_PORT='5432' +POSTGRES_SCHEMAS='shikshalokam' +COMPANY_NAME='Shikshalokam' +COMPANY_SLUG='shikshalokam' +ADMIN_EMAIL='admin@shikshalokam.org' +SETTINGS_DEBUG='True' +S3_MEDIA_URL='https://mohini-static.shikshalokam.org/' +DEFAULT_LOG_LEVEL='INFO' +S3_BASE_URL='https://mohini-static.shikshalokam.org/' +DJANGO_SETTINGS_MODULE='shikshalokam_mohini.settings' +STATIC_ROOT='/var/www/shikshalokam/static/' +BASE_URL='http://localhost:8000' +GOOGLE_APPLICATION_CREDENTIALS='config/secrets.json' +STORAGE_CLOUD_PROVIDER='AWS' +AWS_REGION='ap-south-1' +SG_REPO_AWS_ACCESS_KEY_ID='' +SG_REPO_AWS_SECRET_ACCESS_KEY='' +AWS_ACCESS_KEY_ID='' +AWS_SECRET_ACCESS_KEY='' +S3_BUCKET_NAME='mohini-static.shikshalokam.org' +SENTRY_DSN='' +LLAMA_BASE_URL='' +LLAMA_HF_TOKEN='' +LLAMAFINETUNE_BASE_URL='' +PG_SSL_ROOT_CERT='/etc/ssl/certs/rds_certificate.pem' +PG_SSL_MODE='disable' +SHIKSHALOKAM_BASE_URL="elevate-api.sunbirdsaas.com/project/v1" +GOTENBERG_URL="http://localhost:3002/forms/chromium/convert/html" +RECOMMENDATION_BASE_URL="http://localhost:9002/similarity-score/" +CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:3000,http://localhost:9000,https://.shikshalokam.org,https://.gritworks.ai,https://demo-mitra.shikshalokam> +ALLOWED_HOSTS=localhost,shikshalokam.org,.shikshalokam.org,demo-mitra.shikshalokam.org,mohini.shikshalokam.org,gritworks.ai,.gritworks.ai,devqa-mohini.shikshalokam.or> +VECTOR_DB_BASE_URL=qa-mitra.shikshalokam.org +LLM_RETRY_NUMBER=2 +LOCATION_AUTH='' +LOCATION_BASE_URL="https://sunbirdsaas.com/api/data/v1/location/search" +LANGFUSE_SECRET_KEY="" +LANGFUSE_PUBLIC_KEY="" +LANGFUSE_HOST="https://cloud.langfuse.com" +OPENAI_API_KEY='' +BHASHANI_API_KEY='' +BHASHANI_USER_ID='' +BHASHANI_AUTHORIZATION='' +BHASHANI_PIPELINE_ID='' +BHASHANI_BASE_URL='https://dhruva-api.bhashini.gov.in/services/inference/pipeline' +SARVAM_API_KEY='' +ELEVATE_BASE_URL="https://elevate-api.sunbirdsaas.com/" \ No newline at end of file diff --git a/shikshalokam/.DS_Store b/shikshalokam/.DS_Store new file mode 100644 index 0000000..6bfd087 Binary files /dev/null and b/shikshalokam/.DS_Store differ diff --git a/shikshalokam/__init__.py b/shikshalokam/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shikshalokam/admin/__init__.py b/shikshalokam/admin/__init__.py new file mode 100644 index 0000000..86c3350 --- /dev/null +++ b/shikshalokam/admin/__init__.py @@ -0,0 +1,6 @@ +from .base_admin import * +from .project_vernacular_admin import * +from .wishlist_admin import * +from .category_admin import * +from .learning_resources_admin import * +from .project_admin import * \ No newline at end of file diff --git a/shikshalokam/admin/base_admin.py b/shikshalokam/admin/base_admin.py new file mode 100644 index 0000000..341275a --- /dev/null +++ b/shikshalokam/admin/base_admin.py @@ -0,0 +1,12 @@ +# Import all admin classes for backward compatibility +from shikshalokam.admin.category_admin import CategoryAdmin +from shikshalokam.admin.project_admin import ProjectAdmin, TaskAdmin, EvidenceAdmin +from shikshalokam.admin.learning_resources_admin import LearningResourcesAdmin + +__all__ = [ + 'CategoryAdmin', + 'ProjectAdmin', + 'TaskAdmin', + 'EvidenceAdmin', + 'LearningResourcesAdmin', +] diff --git a/shikshalokam/admin/category_admin.py b/shikshalokam/admin/category_admin.py new file mode 100644 index 0000000..6966005 --- /dev/null +++ b/shikshalokam/admin/category_admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from shikshalokam.models.template_models import Category + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'created_at', ) + list_filter = (CustomAdvanceDateFilter, 'category_id', ) + search_fields = ('title', ) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() \ No newline at end of file diff --git a/shikshalokam/admin/learning_resources_admin.py b/shikshalokam/admin/learning_resources_admin.py new file mode 100644 index 0000000..397d481 --- /dev/null +++ b/shikshalokam/admin/learning_resources_admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from shikshalokam.models import LearningResources + + +@admin.register(LearningResources) +class LearningResourcesAdmin(admin.ModelAdmin): + list_display = ('project', 'name', 'created_at') + list_filter = (CustomAdvanceDateFilter, 'project',) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() \ No newline at end of file diff --git a/shikshalokam/admin/project_admin.py b/shikshalokam/admin/project_admin.py new file mode 100644 index 0000000..4ca69ee --- /dev/null +++ b/shikshalokam/admin/project_admin.py @@ -0,0 +1,60 @@ +from import_export.admin import ExportActionMixin, ImportMixin +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from shikshalokam.models.project_models import Project, Task, Evidence +from shikshalokam.resource import ExpertProjectResource + + +@admin.register(Project) +class ProjectAdmin(ImportMixin, ExportActionMixin, admin.ModelAdmin): + resource_class = ExpertProjectResource + list_display = ('project_id', 'actual_title', 'actual_duration', 'generated_by', 'created_at', ) + list_filter = ( + CustomAdvanceDateFilter, 'project_id', 'author', 'generated_by', 'actual_title', + 'expected_title', 'story__session' + ) + raw_id_fields = ('author', 'story') + readonly_fields = ('solution_download_count', ) + # inlines = [TaskInline, EvidenceInline] + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for instance in instances: + if not instance.pk: + instance.created_by = request.user + instance.save() + formset.save_m2m() + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related('project_template', 'author').prefetch_related('task', 'evidence') + + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + list_display = ('task_name', 'created_at', 'mandatory_task') + list_filter = (CustomAdvanceDateFilter, 'task_id', 'project__project_id') + search_fields = ('task_name', ) + raw_id_fields = ('project',) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() + + +@admin.register(Evidence) +class EvidenceAdmin(admin.ModelAdmin): + list_display = ('evidence_link', 'created_at') + list_filter = (CustomAdvanceDateFilter, 'task__task_name', 'project__project_id',) + search_fields = ('evidence_link', ) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() \ No newline at end of file diff --git a/shikshalokam/admin/project_vernacular_admin.py b/shikshalokam/admin/project_vernacular_admin.py new file mode 100644 index 0000000..93435e7 --- /dev/null +++ b/shikshalokam/admin/project_vernacular_admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from shikshalokam.models.project_vernacular_model import ProjectVernacular + + +@admin.register(ProjectVernacular) +class ProjectVernacularAdmin(admin.ModelAdmin): + list_display = ('project', 'language', 'created_at') + list_filter = (CustomAdvanceDateFilter, 'project', 'language', 'project__project_id') + + raw_id_fields = ('project', 'task') + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + obj.save() diff --git a/shikshalokam/admin/wishlist_admin.py b/shikshalokam/admin/wishlist_admin.py new file mode 100644 index 0000000..60a9d3a --- /dev/null +++ b/shikshalokam/admin/wishlist_admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter +from shikshalokam.models.wishlist_model import ProjectWishlist + + +@admin.register(ProjectWishlist) +class ProjectWishlistAdmin(admin.ModelAdmin): + list_display = ('project', 'author', 'created_at') + list_filter = (CustomAdvanceDateFilter, 'project__id', 'author') + + raw_id_fields = ('project', 'author') diff --git a/shikshalokam/apps.py b/shikshalokam/apps.py new file mode 100644 index 0000000..9be6d7e --- /dev/null +++ b/shikshalokam/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShikshalokamConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'shikshalokam' diff --git a/shikshalokam/migrations/0001_initial.py b/shikshalokam/migrations/0001_initial.py new file mode 100644 index 0000000..8a0f2de --- /dev/null +++ b/shikshalokam/migrations/0001_initial.py @@ -0,0 +1,390 @@ +# Generated by Django 5.1.2 on 2024-10-28 08:07 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('chatbot', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=1000, null=True)), + ('category_id', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'shikshalokam"."category', + }, + ), + migrations.CreateModel( + name='HistoricalCategory', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=1000, null=True)), + ('category_id', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical category', + 'verbose_name_plural': 'historical categorys', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProjectTemplate', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=1000, null=True)), + ('template_id', models.CharField(blank=True, max_length=255, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('category', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.category')), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical project template', + 'verbose_name_plural': 'historical project templates', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=1000, null=True)), + ('project_id', models.CharField(max_length=255, unique=True)), + ('recommended_for', models.CharField(blank=True, max_length=400, null=True)), + ('keywords', models.TextField(blank=True, null=True)), + ('objective', models.TextField(blank=True, null=True)), + ('duration', models.CharField(blank=True, max_length=1000, null=True)), + ('project_status', models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('inPROGRESS', 'inPROGRESS'), ('SUBMITTED', 'SUBMITTED')], max_length=100, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('resource_name', models.CharField(blank=True, max_length=1000, null=True)), + ('resource_link', models.CharField(blank=True, max_length=2000, null=True)), + ('project_start_date', models.DateTimeField(blank=True, null=True)), + ('project_end_date', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project', to='chatbot.profile')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('story', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project', to='chatbot.story')), + ], + options={ + 'db_table': 'shikshalokam"."project', + }, + ), + migrations.CreateModel( + name='HistoricalTask', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('parent_task_id', models.CharField(blank=True, max_length=255, null=True)), + ('task_id', models.CharField(blank=True, max_length=255, null=True)), + ('task_name', models.CharField(blank=True, max_length=1000, null=True)), + ('mandatory_task', models.CharField(blank=True, choices=[('YES', 'YES'), ('NO', 'NO')], max_length=100, null=True)), + ('observation_name', models.CharField(blank=True, max_length=255, null=True)), + ('number_of_submission_observation', models.IntegerField(blank=True, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.project')), + ], + options={ + 'verbose_name': 'historical task', + 'verbose_name_plural': 'historical tasks', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='ProjectTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=1000, null=True)), + ('template_id', models.CharField(blank=True, max_length=255, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_template', to='shikshalokam.category')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'shikshalokam"."project_template', + }, + ), + migrations.AddField( + model_name='project', + name='project_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project', to='shikshalokam.projecttemplate'), + ), + migrations.CreateModel( + name='HistoricalProject', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=1000, null=True)), + ('project_id', models.CharField(db_index=True, max_length=255)), + ('recommended_for', models.CharField(blank=True, max_length=400, null=True)), + ('keywords', models.TextField(blank=True, null=True)), + ('objective', models.TextField(blank=True, null=True)), + ('duration', models.CharField(blank=True, max_length=1000, null=True)), + ('project_status', models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('inPROGRESS', 'inPROGRESS'), ('SUBMITTED', 'SUBMITTED')], max_length=100, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('resource_name', models.CharField(blank=True, max_length=1000, null=True)), + ('resource_link', models.CharField(blank=True, max_length=2000, null=True)), + ('project_start_date', models.DateTimeField(blank=True, null=True)), + ('project_end_date', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('author', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.profile')), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('story', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.story')), + ('project_template', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.projecttemplate')), + ], + options={ + 'verbose_name': 'historical project', + 'verbose_name_plural': 'historical projects', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('parent_task_id', models.CharField(blank=True, max_length=255, null=True)), + ('task_id', models.CharField(blank=True, max_length=255, null=True)), + ('task_name', models.CharField(blank=True, max_length=1000, null=True)), + ('mandatory_task', models.CharField(blank=True, choices=[('YES', 'YES'), ('NO', 'NO')], max_length=100, null=True)), + ('observation_name', models.CharField(blank=True, max_length=255, null=True)), + ('number_of_submission_observation', models.IntegerField(blank=True, null=True)), + ('other_params', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task', to='shikshalokam.project')), + ], + options={ + 'db_table': 'shikshalokam"."task', + }, + ), + migrations.CreateModel( + name='HistoricalEvidence', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('remark', models.CharField(blank=True, max_length=1000, null=True)), + ('evidence_link', models.CharField(blank=True, max_length=2000, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.project')), + ('task', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.task')), + ], + options={ + 'verbose_name': 'historical evidence', + 'verbose_name_plural': 'historical evidences', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Evidence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remark', models.CharField(blank=True, max_length=1000, null=True)), + ('evidence_link', models.CharField(blank=True, max_length=2000, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='evidence', to='shikshalokam.project')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evidence', to='shikshalokam.task')), + ], + options={ + 'db_table': 'shikshalokam"."evidence', + }, + ), + migrations.AddIndex( + model_name='category', + index=models.Index(fields=['name'], name='category_name_d601b7_idx'), + ), + migrations.AddIndex( + model_name='category', + index=models.Index(fields=['category_id'], name='category_categor_ad40b9_idx'), + ), + migrations.AddIndex( + model_name='category', + index=models.Index(fields=['created_at'], name='category_created_74b871_idx'), + ), + migrations.AddIndex( + model_name='projecttemplate', + index=models.Index(fields=['title'], name='project_tem_title_c77d6b_idx'), + ), + migrations.AddIndex( + model_name='projecttemplate', + index=models.Index(fields=['template_id'], name='project_tem_templat_c85469_idx'), + ), + migrations.AddIndex( + model_name='projecttemplate', + index=models.Index(fields=['created_at'], name='project_tem_created_6217eb_idx'), + ), + migrations.AddIndex( + model_name='projecttemplate', + index=models.Index(fields=['category'], name='project_tem_categor_5a668d_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['title'], name='project_title_8f019f_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['project_id'], name='project_project_a46baf_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['recommended_for'], name='project_recomme_f0b0fe_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['keywords'], name='project_keyword_2c9b0b_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['objective'], name='project_objecti_8fdb4d_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['duration'], name='project_duratio_798967_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['project_status'], name='project_project_4ef16a_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['resource_name'], name='project_resourc_c93ada_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['project_start_date'], name='project_project_ffec82_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['project_end_date'], name='project_project_7d2439_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['created_at'], name='project_created_86fac0_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['story'], name='project_story_i_3d895c_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['project_template'], name='project_project_d9c951_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['author'], name='project_author__80fdfd_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['parent_task_id'], name='task_parent__050910_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['task_id'], name='task_task_id_d6360f_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['task_name'], name='task_task_na_f244c5_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['mandatory_task'], name='task_mandato_0ac874_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['observation_name'], name='task_observa_7b3d09_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['number_of_submission_observation'], name='task_number__e776f7_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['created_at'], name='task_created_fe28b4_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['project'], name='task_project_c9b488_idx'), + ), + migrations.AddIndex( + model_name='evidence', + index=models.Index(fields=['remark'], name='evidence_remark_71486e_idx'), + ), + migrations.AddIndex( + model_name='evidence', + index=models.Index(fields=['evidence_link'], name='evidence_evidenc_19d5dd_idx'), + ), + migrations.AddIndex( + model_name='evidence', + index=models.Index(fields=['created_at'], name='evidence_created_507292_idx'), + ), + migrations.AddIndex( + model_name='evidence', + index=models.Index(fields=['task'], name='evidence_task_id_6d7c86_idx'), + ), + migrations.AddIndex( + model_name='evidence', + index=models.Index(fields=['project'], name='evidence_project_12e816_idx'), + ), + ] diff --git a/shikshalokam/migrations/0002_historicalproject_problem_statement_and_more.py b/shikshalokam/migrations/0002_historicalproject_problem_statement_and_more.py new file mode 100644 index 0000000..0e442a7 --- /dev/null +++ b/shikshalokam/migrations/0002_historicalproject_problem_statement_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-02-08 03:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shikshalokam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='historicalproject', + name='problem_statement', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='problem_statement', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/shikshalokam/migrations/0003_historicalproject_actual_duration_and_more.py b/shikshalokam/migrations/0003_historicalproject_actual_duration_and_more.py new file mode 100644 index 0000000..d0fa91a --- /dev/null +++ b/shikshalokam/migrations/0003_historicalproject_actual_duration_and_more.py @@ -0,0 +1,93 @@ +# Generated by Django 5.1.2 on 2025-02-19 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shikshalokam', '0002_historicalproject_problem_statement_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalproject', + name='actual_duration', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='actual_objective', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='actual_problem_statement', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='actual_title', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='expected_duration', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='expected_objective', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='expected_problem_statement', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='expected_title', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='project', + name='actual_duration', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='project', + name='actual_objective', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='actual_problem_statement', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='actual_title', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='project', + name='expected_duration', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='project', + name='expected_objective', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='expected_problem_statement', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='expected_title', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/shikshalokam/migrations/0004_historicallearningresources_and_more.py b/shikshalokam/migrations/0004_historicallearningresources_and_more.py new file mode 100644 index 0000000..b67ec7c --- /dev/null +++ b/shikshalokam/migrations/0004_historicallearningresources_and_more.py @@ -0,0 +1,422 @@ +# Generated by Django 5.1.2 on 2025-02-20 10:55 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chatbot', '0020_remove_companybot_max_token_and_more'), + ('shikshalokam', '0003_historicalproject_actual_duration_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalLearningResources', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=1000, null=True)), + ('link', models.CharField(blank=True, max_length=2000, null=True)), + ('resource_id', models.CharField(blank=True, max_length=500, null=True)), + ('app', models.CharField(blank=True, max_length=500, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical learning resources', + 'verbose_name_plural': 'historical learning resourcess', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProjectVernacular', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('language', models.CharField(max_length=250)), + ('details', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical project vernacular', + 'verbose_name_plural': 'historical project vernaculars', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProjectWishlist', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created_at', models.DateTimeField(blank=True, editable=False)), + ('updated_at', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ], + options={ + 'verbose_name': 'historical project wishlist', + 'verbose_name_plural': 'historical project wishlists', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='LearningResources', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=1000, null=True)), + ('link', models.CharField(blank=True, max_length=2000, null=True)), + ('resource_id', models.CharField(blank=True, max_length=500, null=True)), + ('app', models.CharField(blank=True, max_length=500, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'shikshalokam"."learning_resource', + }, + ), + migrations.CreateModel( + name='ProjectVernacular', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language', models.CharField(max_length=250)), + ('details', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'shikshalokam"."project_vernacular', + }, + ), + migrations.CreateModel( + name='ProjectWishlist', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'shikshalokam"."project_wishlist', + }, + ), + migrations.AddField( + model_name='evidence', + name='type', + field=models.CharField(blank=True, max_length=250, null=True), + ), + migrations.AddField( + model_name='historicalevidence', + name='type', + field=models.CharField(blank=True, max_length=250, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='categories', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='generated_by', + field=models.CharField(choices=[('AI_GENERATED', 'AI_GENERATED'), ('EXPERT_VETTED', 'EXPERT_VETTED')], default='AI_GENERATED', max_length=100), + ), + migrations.AddField( + model_name='historicalproject', + name='program_id', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='program_name', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='program_source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='project_language', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='project_source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalproject', + name='template_id', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name='historicaltask', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicaltask', + name='source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicaltask', + name='task_status', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='project', + name='categories', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='generated_by', + field=models.CharField(choices=[('AI_GENERATED', 'AI_GENERATED'), ('EXPERT_VETTED', 'EXPERT_VETTED')], default='AI_GENERATED', max_length=100), + ), + migrations.AddField( + model_name='project', + name='program_id', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name='project', + name='program_name', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + migrations.AddField( + model_name='project', + name='program_source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='project_language', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='project', + name='project_source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='template_id', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name='task', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='task', + name='source', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='task', + name='task_status', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalproject', + name='project_id', + field=models.CharField(db_index=True, max_length=500), + ), + migrations.AlterField( + model_name='historicalproject', + name='project_status', + field=models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('inPROGRESS', 'inPROGRESS'), ('SUBMITTED', 'SUBMITTED'), ('PUBLISHED', 'PUBLISHED')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='historicalproject', + name='recommended_for', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='project', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project', to='chatbot.profile'), + ), + migrations.AlterField( + model_name='project', + name='project_id', + field=models.CharField(max_length=500, unique=True), + ), + migrations.AlterField( + model_name='project', + name='project_status', + field=models.CharField(blank=True, choices=[('STARTED', 'STARTED'), ('inPROGRESS', 'inPROGRESS'), ('SUBMITTED', 'SUBMITTED'), ('PUBLISHED', 'PUBLISHED')], max_length=100, null=True), + ), + migrations.AlterField( + model_name='project', + name='recommended_for', + field=models.TextField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['actual_title'], name='project_actual__583ea5_idx'), + ), + migrations.AddField( + model_name='historicallearningresources', + name='created_by', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicallearningresources', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicallearningresources', + name='project', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.project'), + ), + migrations.AddField( + model_name='historicalprojectvernacular', + name='created_by', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalprojectvernacular', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalprojectvernacular', + name='project', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.project'), + ), + migrations.AddField( + model_name='historicalprojectvernacular', + name='task', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.task'), + ), + migrations.AddField( + model_name='historicalprojectwishlist', + name='author', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='chatbot.profile'), + ), + migrations.AddField( + model_name='historicalprojectwishlist', + name='history_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalprojectwishlist', + name='project', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='shikshalokam.project'), + ), + migrations.AddField( + model_name='learningresources', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='learningresources', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='learning_resource', to='shikshalokam.project'), + ), + migrations.AddField( + model_name='projectvernacular', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='projectvernacular', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_vernacular', to='shikshalokam.project'), + ), + migrations.AddField( + model_name='projectvernacular', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_vernacular', to='shikshalokam.task'), + ), + migrations.AddField( + model_name='projectwishlist', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wishlist', to='chatbot.profile'), + ), + migrations.AddField( + model_name='projectwishlist', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wishlist', to='shikshalokam.project'), + ), + migrations.AddIndex( + model_name='learningresources', + index=models.Index(fields=['name'], name='learning_re_name_1d34fd_idx'), + ), + migrations.AddIndex( + model_name='learningresources', + index=models.Index(fields=['link'], name='learning_re_link_3ada67_idx'), + ), + migrations.AddIndex( + model_name='learningresources', + index=models.Index(fields=['created_at'], name='learning_re_created_9ceff0_idx'), + ), + migrations.AddIndex( + model_name='learningresources', + index=models.Index(fields=['resource_id'], name='learning_re_resourc_dea422_idx'), + ), + migrations.AddIndex( + model_name='learningresources', + index=models.Index(fields=['project'], name='learning_re_project_ad000b_idx'), + ), + migrations.AddIndex( + model_name='projectvernacular', + index=models.Index(fields=['language'], name='project_ver_languag_c76e83_idx'), + ), + migrations.AddIndex( + model_name='projectvernacular', + index=models.Index(fields=['created_at'], name='project_ver_created_14765c_idx'), + ), + migrations.AddIndex( + model_name='projectvernacular', + index=models.Index(fields=['project'], name='project_ver_project_43ced1_idx'), + ), + migrations.AddIndex( + model_name='projectvernacular', + index=models.Index(fields=['task'], name='project_ver_task_id_bce298_idx'), + ), + migrations.AddIndex( + model_name='projectwishlist', + index=models.Index(fields=['author'], name='project_wis_author__01388d_idx'), + ), + migrations.AddIndex( + model_name='projectwishlist', + index=models.Index(fields=['project'], name='project_wis_project_983af9_idx'), + ), + migrations.AddIndex( + model_name='projectwishlist', + index=models.Index(fields=['created_at'], name='project_wis_created_6951d7_idx'), + ), + ] diff --git a/shikshalokam/migrations/0005_historicalproject_solution_download_count_and_more.py b/shikshalokam/migrations/0005_historicalproject_solution_download_count_and_more.py new file mode 100644 index 0000000..7de760f --- /dev/null +++ b/shikshalokam/migrations/0005_historicalproject_solution_download_count_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-12-29 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shikshalokam', '0004_historicallearningresources_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalproject', + name='solution_download_count', + field=models.PositiveBigIntegerField(default=0), + ), + migrations.AddField( + model_name='project', + name='solution_download_count', + field=models.PositiveBigIntegerField(default=0), + ), + ] diff --git a/shikshalokam/migrations/__init__.py b/shikshalokam/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shikshalokam/models/__init__.py b/shikshalokam/models/__init__.py new file mode 100644 index 0000000..4e0fb5f --- /dev/null +++ b/shikshalokam/models/__init__.py @@ -0,0 +1,6 @@ +from .base_model import * +from .project_vernacular_model import * +from .wishlist_model import * +from .enums import * +from .project_models import * +from .template_models import * diff --git a/shikshalokam/models/base_model.py b/shikshalokam/models/base_model.py new file mode 100644 index 0000000..5216aaa --- /dev/null +++ b/shikshalokam/models/base_model.py @@ -0,0 +1,18 @@ +""" +Base model file - Re-exports all models for backward compatibility. +Actual model definitions are now in specialized files: +- template_models.py: Category, ProjectTemplate +- project_models.py: Project, Task, Evidence, LearningResources +""" + +from shikshalokam.models.template_models import Category, ProjectTemplate +from shikshalokam.models.project_models import Project, Task, Evidence, LearningResources + +__all__ = [ + 'Category', + 'ProjectTemplate', + 'Project', + 'Task', + 'Evidence', + 'LearningResources', +] diff --git a/shikshalokam/models/enums.py b/shikshalokam/models/enums.py new file mode 100644 index 0000000..d9f4d3b --- /dev/null +++ b/shikshalokam/models/enums.py @@ -0,0 +1,34 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ProjectUserType(models.TextChoices): + PRINCIPAL = 'PRINCIPAL', _('PRINCIPAL') + TEACHER = 'TEACHER', _('TEACHER') + SPD = 'SPD', _('SPD') + DEO = 'DEO', _('DEO') + BEO = 'BEO', _('BEO') + HM = 'HM', _('HM') + HT = 'HT', _('HT') + AT = 'AT', _('AT') + + +class ProjectStatus(models.TextChoices): + STARTED = 'STARTED', _('STARTED') + IN_PROGRESS = 'inPROGRESS', _('inPROGRESS') + SUBMITTED = 'SUBMITTED', _('SUBMITTED') + PUBLISHED = 'PUBLISHED', _('PUBLISHED') + + +class TaskMandatoryStatus(models.TextChoices): + YES = 'YES', _('YES') + NO = 'NO', _('NO') + +class ProjectCreatedBy(models.TextChoices): + AI_GENERATED = 'AI_GENERATED', _('AI_GENERATED') + EXPERT_VETTED = 'EXPERT_VETTED', _('EXPERT_VETTED') + +class PriorityChoices(models.TextChoices): + P1 = 'P1', _('P1') + P2 = 'P2', _('P2') + P3 = 'P3', _('P3') diff --git a/shikshalokam/models/project_models.py b/shikshalokam/models/project_models.py new file mode 100644 index 0000000..21f0eae --- /dev/null +++ b/shikshalokam/models/project_models.py @@ -0,0 +1,178 @@ +import os + +from django.contrib.auth.models import User +from django.db import models +from django_s3_storage.storage import S3Storage +from simple_history.models import HistoricalRecords + +from chatbot.models import Profile, Story +from shikshalokam.models.enums import ProjectStatus, TaskMandatoryStatus, ProjectCreatedBy + +storage = S3Storage(aws_s3_bucket_name='mohini-static.shikshalokam.org') +S3_BASE_URL = os.getenv('S3_MEDIA_URL') + + +class Project(models.Model): + story = models.ForeignKey(Story, related_name='project', on_delete=models.SET_NULL, null=True, blank=True) + project_template = models.ForeignKey('shikshalokam.ProjectTemplate', on_delete=models.CASCADE, related_name="project", + null=True, blank=True) + author = models.ForeignKey(Profile, on_delete=models.SET_NULL, null=True, blank=True, related_name="project") + + categories = models.TextField(null=True, blank=True) + description = models.TextField(null=True, blank=True) + + title = models.CharField(max_length=1000, null=True, blank=True) + expected_title = models.CharField(max_length=1000, null=True, blank=True) + actual_title = models.CharField(max_length=1000, null=True, blank=True) + + problem_statement = models.TextField(null=True, blank=True) + expected_problem_statement = models.TextField(null=True, blank=True) + actual_problem_statement = models.TextField(null=True, blank=True) + + template_id = models.CharField(max_length=500, null=True, blank=True) + project_id = models.CharField(max_length=500, unique=True) + program_id = models.CharField(max_length=500, null=True, blank=True) + program_name = models.CharField(max_length=1000, null=True, blank=True) + + recommended_for = models.TextField(null=True, blank=True) + keywords = models.TextField(null=True, blank=True) + + objective = models.TextField(null=True, blank=True) + expected_objective = models.TextField(null=True, blank=True) + actual_objective = models.TextField(null=True, blank=True) + + duration = models.CharField(max_length=1000, null=True, blank=True) + expected_duration = models.CharField(max_length=1000, null=True, blank=True) + actual_duration = models.CharField(max_length=1000, null=True, blank=True) + + project_status = models.CharField(max_length=100, choices=ProjectStatus.choices, null=True, blank=True) + generated_by = models.CharField(max_length=100, choices=ProjectCreatedBy.choices, + default=ProjectCreatedBy.AI_GENERATED) + + other_params = models.JSONField(null=True, blank=True) + project_language = models.CharField(max_length=100, null=True, blank=True) + + project_source = models.TextField(null=True, blank=True) + program_source = models.TextField(null=True, blank=True) + + resource_name = models.CharField(max_length=1000, null=True, blank=True) + resource_link = models.CharField(max_length=2000, null=True, blank=True) + + project_start_date = models.DateTimeField(null=True, blank=True) + project_end_date = models.DateTimeField(null=True, blank=True) + solution_download_count = models.PositiveBigIntegerField(default=0) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + def __str__(self): + return self.title if self.title else "Unnamed Project" + + class Meta: + db_table = 'shikshalokam"."project' + indexes = [ + models.Index(fields=['title']), + models.Index(fields=['actual_title']), + models.Index(fields=['project_id']), + models.Index(fields=['recommended_for']), + models.Index(fields=['keywords']), + models.Index(fields=['objective']), + models.Index(fields=['duration']), + models.Index(fields=['project_status']), + models.Index(fields=['resource_name']), + models.Index(fields=['project_start_date']), + models.Index(fields=['project_end_date']), + models.Index(fields=['created_at']), + models.Index(fields=['story']), + models.Index(fields=['project_template']), + models.Index(fields=['author']), + ] + + +class Task(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='task') + parent_task_id = models.CharField(max_length=255, null=True, blank=True) + task_id = models.CharField(max_length=255, null=True, blank=True) + task_name = models.CharField(max_length=1000, null=True, blank=True) + mandatory_task = models.CharField(max_length=100, choices=TaskMandatoryStatus.choices, null=True, blank=True) + observation_name = models.CharField(max_length=255, null=True, blank=True) + number_of_submission_observation = models.IntegerField(null=True, blank=True) + other_params = models.JSONField(null=True, blank=True) + task_status = models.CharField(max_length=100, null=True, blank=True) + description = models.TextField(null=True, blank=True) + source = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."task' + indexes = [ + models.Index(fields=['parent_task_id']), + models.Index(fields=['task_id']), + models.Index(fields=['task_name']), + models.Index(fields=['mandatory_task']), + models.Index(fields=['observation_name']), + models.Index(fields=['number_of_submission_observation']), + models.Index(fields=['created_at']), + models.Index(fields=['project']), + ] + + +class Evidence(models.Model): + task = models.ForeignKey(Task, on_delete=models.SET_NULL, related_name='evidence', null=True, blank=True) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='evidence', null=True, blank=True) + + remark = models.CharField(max_length=1000, null=True, blank=True) + evidence_link = models.CharField(max_length=2000, null=True, blank=True) + + type = models.CharField(max_length=250, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."evidence' + indexes = [ + models.Index(fields=['remark']), + models.Index(fields=['evidence_link']), + models.Index(fields=['created_at']), + models.Index(fields=['task']), + models.Index(fields=['project']), + ] + + +class LearningResources(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='learning_resource', + null=True, blank=True) + + name = models.CharField(max_length=1000, null=True, blank=True) + link = models.CharField(max_length=2000, null=True, blank=True) + + resource_id = models.CharField(max_length=500, null=True, blank=True) + app = models.CharField(max_length=500, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."learning_resource' + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['link']), + models.Index(fields=['created_at']), + models.Index(fields=['resource_id']), + models.Index(fields=['project']), + ] \ No newline at end of file diff --git a/shikshalokam/models/project_vernacular_model.py b/shikshalokam/models/project_vernacular_model.py new file mode 100644 index 0000000..107370f --- /dev/null +++ b/shikshalokam/models/project_vernacular_model.py @@ -0,0 +1,34 @@ +import os +from django.contrib.auth.models import User +from django.db import models +from django_s3_storage.storage import S3Storage +from simple_history.models import HistoricalRecords +from shikshalokam.models import Project, Task + +storage = S3Storage(aws_s3_bucket_name='mohini-static.shikshalokam.org') +S3_BASE_URL = os.getenv('S3_MEDIA_URL') + + +class ProjectVernacular(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='project_vernacular', + null=True, blank=True) + task = models.ForeignKey(Task, on_delete=models.SET_NULL, related_name='project_vernacular', + null=True, blank=True) + + language = models.CharField(max_length=250) + details = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."project_vernacular' + indexes = [ + models.Index(fields=['language']), + models.Index(fields=['created_at']), + models.Index(fields=['project']), + models.Index(fields=['task']), + ] diff --git a/shikshalokam/models/template_models.py b/shikshalokam/models/template_models.py new file mode 100644 index 0000000..d20d160 --- /dev/null +++ b/shikshalokam/models/template_models.py @@ -0,0 +1,46 @@ +from django.contrib.auth.models import User +from django.db import models +from simple_history.models import HistoricalRecords + + +class Category(models.Model): + name = models.CharField(max_length=1000, null=True, blank=True) + category_id = models.CharField(max_length=255, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."category' + + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['category_id']), + models.Index(fields=['created_at']), + ] + + +class ProjectTemplate(models.Model): + category = models.ForeignKey(Category, on_delete=models.SET_NULL, related_name="project_template", + null=True, blank=True) + title = models.CharField(max_length=1000, null=True, blank=True) + template_id = models.CharField(max_length=255, null=True, blank=True) + description = models.TextField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."project_template' + indexes = [ + models.Index(fields=['title']), + models.Index(fields=['template_id']), + models.Index(fields=['created_at']), + models.Index(fields=['category']), + ] \ No newline at end of file diff --git a/shikshalokam/models/wishlist_model.py b/shikshalokam/models/wishlist_model.py new file mode 100644 index 0000000..dd424b3 --- /dev/null +++ b/shikshalokam/models/wishlist_model.py @@ -0,0 +1,22 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from chatbot.models import Profile +from shikshalokam.models import Project + + +class ProjectWishlist(models.Model): + author = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="wishlist") + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='wishlist') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + history = HistoricalRecords() + + class Meta: + db_table = 'shikshalokam"."project_wishlist' + + indexes = [ + models.Index(fields=['author']), + models.Index(fields=['project']), + models.Index(fields=['created_at']), + ] diff --git a/shikshalokam/resource.py b/shikshalokam/resource.py new file mode 100644 index 0000000..05a3125 --- /dev/null +++ b/shikshalokam/resource.py @@ -0,0 +1,283 @@ +import json +from import_export import resources +from django.db import transaction, IntegrityError +from chatbot.models import Profile, Company +from chatbot.models.geo_models import ProfileAddress +from shikshalokam.models import ProjectStatus, TaskMandatoryStatus +from shikshalokam.models.project_models import (Project, Task, Evidence) +from shikshalokam.models.template_models import (Category, ProjectTemplate) +from import_export.results import Result +from import_export.fields import Field +from shikshalokam.scripts.template_ingestion import process_project_ingestion + + +class ProjectResource(resources.ModelResource): + + program_name_column_name = 'Program Name' + program_id_column_name = 'Program ID' + + UUID_column_name = 'UUID' + user_type_column_name = 'User Type' + user_sub_type_column_name = 'User sub type' + declared_board_column_name = 'Declared Board' + org_associated_column_name = 'Org Name' + state_column_name = 'Declared State' + district_column_name = 'District' + block_column_name = 'Block' + + category_name_column_name = "Category" + category_id_column_name = "Category ID" + + template_title_column_name = "Solution" + template_id_column_name = "Solution ID" + template_description_column_name = "Solution Description" + + project_id_column_name = 'Project ID' + title_column_name = 'Project Title' + objective_column_name = 'Project Objective' + duration_column_name = 'Project Duration' + status_column_name = 'Project Status' + start_date_column_name = 'Project start date of the user' + end_date_column_name = 'Project completion date of the user' + recommended_for_column_name = 'recommendedFor' + keywords_column_name = 'keywords' + + project_resource_name_column_name = 'Project Learning Resource Name' + project_resource_link_column_name = 'Project Learning Resource Link' + project_evidence_column_name = 'Project Evidence' + project_remarks_column_name = 'Project Remarks' + + task_name_column_name = 'Tasks' + task_id_column_name = 'Task ID' + task_mandatory_task_column_name = 'Task Status' + task_observation_name_column_name = 'Observation' + task_number_of_submission_observation_column_name = 'Number of submission' + # sub_task_name_column_name = 'Sub-Tasks' + # task_resource_name_column_name = 'Task Learning Resource Name' + # task_resource_link_column_name = 'Task Learning Resource Link' + task_evidence_column_name = 'Task Evidence' + task_remarks_column_name = 'Task Remarks' + + project_id = Field(attribute='project_id', column_name=project_id_column_name) + + class Meta: + model = Project + import_id_fields = ('project_id',) + fields = '__all__' + + def before_import(self, dataset, using_transactions, dry_run, **kwargs): + # print("Starting before_import") + user_company = Company.objects.get(slug='shikshalokam') + # programs_by_id = {program.program_id: program for program in Program.objects.all()} + # programs_by_name = {program.title: program for program in Program.objects.all()} + + grouped_rows = {} + for row in dataset.dict: + project_id = row.get(self.project_id_column_name) + if project_id not in grouped_rows: + grouped_rows[project_id] = [] + grouped_rows[project_id].append(row) + + # print("Grouped rows by project_id:", len(grouped_rows)) + + if dry_run: + print("Dry run mode - No changes will be committed to the database.") + return + + for project_id, project_rows in grouped_rows.items(): + if not project_id: + # print(f"Skipping rows with missing project_id: {project_rows}") + continue + # print('Processing project ID:', project_id) + # print('Number of rows:', len(project_rows)) + for project_row in project_rows: + with transaction.atomic(): + try: + first_name = project_row.get(self.UUID_column_name) + if not first_name or first_name == '': + print(f"Missing first_name for row: {project_row}") + raise ValueError("Missing first_name, rolling back transaction") + print('FIRST NAME: ', first_name) + designation = project_row.get(self.user_type_column_name) + other_params = { + 'user_sub_type': project_row.get(self.user_sub_type_column_name), + 'declared_board': project_row.get(self.declared_board_column_name) + } + org_associated = project_row.get(self.org_associated_column_name) + + user_email = '{}@{}.com'.format(first_name, 'shikshalokam') + if not user_company or not user_email: + print(f"Invalid company or email for first_name: {first_name}") + continue + print(f"Fetching/Creating Profile for email: {user_email}") + + author, author_created = Profile.objects.get_or_create( + email=user_email, company=user_company, + defaults={ + 'first_name': first_name, + 'designation': designation, + 'other_params': other_params, + 'org_associated': org_associated, + } + ) + print(f"Profile created: {author_created}, Profile ID: {author.id if author else 'None'}") + if author_created: + ProfileAddress.objects.create( + profile=author, + state=project_row.get(self.state_column_name), + district=project_row.get(self.district_column_name), + city=project_row.get(self.block_column_name), + ) + + category_name = project_row.get(self.category_name_column_name) + category_id = project_row.get(self.category_id_column_name) + category, _ = Category.objects.get_or_create( + name=category_name, + category_id=category_id + ) + # Even if category is not there we will still create Project Template instance + template_title = project_row.get(self.template_title_column_name) + template_id = project_row.get(self.template_id_column_name) + description = project_row.get(self.template_description_column_name) + project_template, _ = ProjectTemplate.objects.get_or_create( + category=category, + title=template_title, + template_id=template_id, + description=description + ) + + project_title = project_row.get(self.title_column_name) + project_objective = project_row.get(self.objective_column_name) + project_duration = project_row.get(self.duration_column_name) + project_status = project_row.get(self.status_column_name) + project_recommended_for = project_row.get(self.recommended_for_column_name) + project_keywords = project_row.get(self.keywords_column_name) + project_resource_name = project_row.get(self.project_resource_name_column_name) + project_resource_link = project_row.get(self.project_resource_link_column_name) + if project_status: + project_status = project_status.strip().lower() + for status_choice in ProjectStatus.choices: + if project_status == status_choice[0].lower(): + project_status = status_choice[0] + break + project_start_date = project_row.get(self.start_date_column_name) + project_end_date = project_row.get(self.end_date_column_name) + print(f"Creating/Updating Project for author: {author.id}, email: {author.email}") + print(f"Debug - Project ID: {project_id}, Project Row: {project_row}") + # program_name = project_row.get(self.program_name_column_name) + # program_id = project_row.get(self.program_id_column_name) + # program = None + # if program_id: + # program = programs_by_id.get(program_id) + # elif program_name: + # program = programs_by_name.get(program_name) + project, created = Project.objects.get_or_create( + project_id=project_id, + defaults={ + 'project_template': project_template, + 'author': author, + 'title': project_title, + 'objective': project_objective, + 'duration': project_duration, + 'project_status': project_status, + 'project_start_date': project_start_date, + 'project_end_date': project_end_date, + 'recommended_for': project_recommended_for, + 'keywords': project_keywords, + 'resource_name': project_resource_name, + 'resource_link': project_resource_link + } + ) + print(f"Project created: {created}, Project ID: {project.id if project else 'None'}") + + if not created: + print(f"Project already exists. Updating Project ID: {project_id}") + print( + f"Since project exist : Creating/Updating Project for author: {author.id}, email: {author.email}") + project.project_template = project_template + project.author = author + project.title = project_title + project.objective = project_objective + project.duration = project_duration + project.project_status = project_status + project.project_start_date = project_start_date + project.project_end_date = project_end_date + project.recommended_for = project_recommended_for + project.keywords = project_keywords + project.resource_name = project_resource_name + project.resource_link = project_resource_link + project.save() + + self.import_tasks(project_row, project) + + print(f"Successfully processed row for project ID: {project_id}") + + except IntegrityError as e: + print(f"IntegrityError in row for project ID: {project_id} - {str(e)}") + except Exception as e: + print(f"Error in row for project ID: {project_id} - {str(e)}") + + print("Import complete - Changes committed to the database.") + + def import_data(self, dataset, dry_run=False, raise_errors=False, **kwargs): + return super().import_data(dataset, dry_run=False, raise_errors=raise_errors, **kwargs) + + def import_tasks(self, row, project): + task_name = row.get(self.task_name_column_name) + parent_task_id = row.get(self.task_id_column_name) + task_id = row.get(self.task_id_column_name) + mandatory_task = row.get(self.task_mandatory_task_column_name) + observation_name = row.get(self.task_observation_name_column_name) + number_of_submission_observation = row.get(self.task_number_of_submission_observation_column_name) + if mandatory_task: + mandatory_task = mandatory_task.strip().lower() + for task_choice in TaskMandatoryStatus.choices: + if mandatory_task == task_choice[0].lower(): + mandatory_task = task_choice[0] + break + task, _ = Task.objects.get_or_create( + project=project, + defaults={ + 'task_name': task_name, + 'parent_task_id': parent_task_id, + 'task_id': task_id, + 'mandatory_task': mandatory_task, + 'observation_name': observation_name, + 'number_of_submission_observation': number_of_submission_observation + } + ) + self.import_evidence(row, task, project) + + def import_evidence(self, row, task, project): + evidence_link_task = row.get(self.task_evidence_column_name) + remark_task = row.get(self.task_remarks_column_name) + + evidence_link_project = row.get(self.project_evidence_column_name) + remark_project = row.get(self.project_remarks_column_name) + + if evidence_link_project: + Evidence.objects.get_or_create( + project=project, + evidence_link=evidence_link_project, + remark=remark_project + ) + + if evidence_link_task: + Evidence.objects.get_or_create( + task=task, + evidence_link=evidence_link_task, + remark=remark_task + ) + +class ExpertProjectResource(resources.ModelResource): + class Meta: + model = Project + + def import_data(self, dataset, dry_run=False, *args, **kwargs): + json_list_data = dataset.dict + if json_list_data and not isinstance(json_list_data, (list, dict)): + json_list_data = json.loads(json_list_data) + process_project_ingestion(json_list=json_list_data) + + result = Result() + return result diff --git a/shikshalokam/scripts/__init__.py b/shikshalokam/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shikshalokam/scripts/template_ingestion.py b/shikshalokam/scripts/template_ingestion.py new file mode 100644 index 0000000..b41ebf8 --- /dev/null +++ b/shikshalokam/scripts/template_ingestion.py @@ -0,0 +1,226 @@ +import json +from django.db import transaction + +from chatbot.models import Profile, Company +from chatbot.models.geo_models import ProfileAddress +from shikshalokam.models import Project, Evidence, ProjectCreatedBy, LearningResources, Task +from shikshalokam.models.project_vernacular_model import ProjectVernacular + + +def ingest_project_template(file_path): + # file_path = 'shikshalokam/scripts/projectTemplateJson.json' + json_data = load_json(file_path) + results = json_data + + if not results: + print("No projects found in the provided JSON file.") + return + + try: + process_project_ingestion(json_list=results) + except Exception as e: + print(f"Error during project ingestion: {e}") + + +def ingest_task_data(file_path): + # file_path = 'shikshalokam/scripts/TemplateTask.json' + json_data = load_json(file_path) + results = json_data + + if not results: + print("No tasks found in the provided JSON file.") + return + + try: + with transaction.atomic(): + task_dict = {} + missing_project_tasks = [] + + for task_data in results: + project_id = task_data.get('projectTemplateId') + try: + project = Project.objects.get(project_id=project_id) + except Project.DoesNotExist: + print( + f"Project with project_id '{project_id}' does not exist. Skipping task " + f"'{task_data.get('name')}'.") + missing_project_tasks.append(project_id) + continue + + task, task_created = Task.objects.get_or_create( + project=project, + task_id=task_data.get('_id'), + defaults={ + "task_name": task_data.get('name'), + "description": task_data.get('description'), + "other_params": { + 'solution_details': task_data.get('solutionDetails', {}), + 'task_sequence': task_data.get('taskSequence', {}), + }, + } + ) + + task_dict[task_data.get('_id')] = task + + if task_created: + print(f"Task '{task.task_name}' created successfully.") + else: + print(f"Task '{task.task_name}' already exists.") + + translations = task_data.get('translations', {}) + for language, details in translations.items(): + vernacular, vernacular_created = ProjectVernacular.objects.get_or_create( + task=task, + language=language, + defaults={ + "details": json.dumps(details) + } + ) + if vernacular_created: + print(f"Translation for language '{language}' saved for task '{task.task_name}'.") + else: + vernacular.details = json.dumps(details) + vernacular.save() + print(f"Translation for language '{language}' updated for task '{task.task_name}'.") + + for task_data in results: + parent_task = task_dict.get(task_data.get('_id')) + + if 'children' in task_data and parent_task: + for child_task_id in task_data['children']: + if child_task_id in task_dict: + child_task = task_dict[child_task_id] + child_task.parent_task_id = parent_task.task_id + child_task.save() + print(f"Assigned parent_task_id '{parent_task.task_id}' " + f"to child task '{child_task.task_id}'.") + if missing_project_tasks: + print("Tasks with missing projects: ", missing_project_tasks) + + except Exception as e: + print(f"Error during task ingestion: {e}") + + + +def load_json(file_path): + with open(file_path, 'r') as file: + data = json.load(file) + return data + + +def process_project_ingestion(json_list): + try: + with transaction.atomic(): + for result in json_list: + + try: + author_detail = result.get('author') + # if not author_detail: + # raise ValueError("Author detail is missing") + if author_detail: + name, location = map(str.strip, author_detail.split(",", 1)) + email = (result.get('_id') or "") + "@shikshalokam.org" + company = Company.objects.filter(slug='shikshalokamstaging').first() + profile, profile_created = Profile.objects.update_or_create( + email=email, + defaults={ + "first_name": name, + "company": company, + "password": 'grit@123', + } + ) + if profile_created: + print(f"Profile '{profile.id}' created successfully.") + else: + print(f"Profile '{profile.id}' already exists.") + + ProfileAddress.objects.update_or_create( + profile=profile, + defaults={ + "state": location + } + ) + else: + profile = None + except Exception as e: + print("Profile error: ", e) + profile = None + + project, created = Project.objects.get_or_create( + project_id=result.get('_id'), + defaults={ + "author": profile, + "template_id": result.get('_id'), + "description": result.get('description'), + "keywords": json.dumps(result.get('keywords')), + "recommended_for": json.dumps(result.get('recommendedFor')), + "actual_title": result.get('title'), + "categories": json.dumps(result.get('categories')), + "actual_duration": result.get('metaInformation', {}).get('duration', None), + "actual_problem_statement": result.get('problemStatement'), + "program_id": result.get('programId'), + "other_params": { + 'text': result.get('text'), + 'impact': result.get('impact'), + 'summary': result.get('summary'), + 'template_author': result.get('author'), + }, + "project_status": result.get('status', '').upper(), + "generated_by": ProjectCreatedBy.EXPERT_VETTED + } + ) + + if created: + print(f"Project '{project.actual_title}' created successfully.") + else: + print(f"Project '{project.actual_title}' already exists.") + + evidences = result.get('evidences', []) + for evidence_data in evidences: + evidence, evidence_created = Evidence.objects.get_or_create( + project=project, + evidence_link=evidence_data.get('link'), + remark=evidence_data.get('title'), + defaults={ + "type": evidence_data.get('type') + } + ) + if evidence_created: + print(f"Evidence '{evidence.remark}' saved for project '{project.actual_title}'.") + else: + print(f"Evidence '{evidence.remark}' already exists for project '{project.actual_title}'.") + + learning_resources = result.get('learningResources', []) + for lr_data in learning_resources: + lr, lr_created = LearningResources.objects.get_or_create( + project=project, + link=lr_data.get('link'), + name=lr_data.get('name'), + defaults={ + "resource_id": lr_data.get('id'), + "app": lr_data.get('app') + } + ) + if lr_created: + print(f"Learning resource '{lr.name}' saved for project '{project.actual_title}'.") + else: + print(f"Learning resource '{lr.name}' already exists for project '{project.actual_title}'.") + + translations = result.get('translations', {}) + for language, details in translations.items(): + vernacular, vernacular_created = ProjectVernacular.objects.get_or_create( + project=project, + language=language, + defaults={ + "details": json.dumps(details) + } + ) + if vernacular_created: + print(f"Translation for language '{language}' saved for project '{project.actual_title}'.") + else: + vernacular.details = json.dumps(details) + vernacular.save() + print(f"Translation for language '{language}' updated for project '{project.actual_title}'.") + except Exception as e: + print(f"Error during project ingestion: {e}") + raise \ No newline at end of file diff --git a/shikshalokam/scripts/training_data_creation.py b/shikshalokam/scripts/training_data_creation.py new file mode 100644 index 0000000..76e445b --- /dev/null +++ b/shikshalokam/scripts/training_data_creation.py @@ -0,0 +1,20 @@ +from django.db.models import Q + +from chatbot.models import Company, CompanyChat + +filtered_names = ['NAGASHETTY BHADRASHETTY','Madhavi','Shankarayya Hiremath','Jayalaxmi','MALKANNA HACHCHADAD','ZOHRA KHANUM','Vandana','Ganapati Bhat','ASHWINI','Vinutha','Savitha','Dinesh Reddy','PRABHUGOUDA','Shashikala','Vijayashree','Sarah','Joffin','Shabana Yasmeen','Anurag','Khrielavonuo','Khayal Sharma','Vijayalaxmi','Archana Hegde','Indira Badiger','Irayya Hiremath','Chubalemla Chang','SRIKRISHNA SETTY','maruti','Rajshekhar S M','Narsappa Rahul','MOHAMMAD RAFI TAWARGERI','Yizano kikon','JAGADISH','Ramesh Rathod','T Akali Kibami','Srinivasa R V','MALAPPA PUJARI','NAIKAR VEENA','Pusazonu','Mansi','Medozhonu','Bhumika','Joyeeta','Sanjana','SHUKURMIYA M','Patrick','Shivaraj','Manjula','GURUDEVI','RAVI F JORAPUR','Dennis','Noyingi Lotha','Sachin','Shrishail','Ankit','Sevanta','Benchumlo H','Aruna','Manoj','Moainla Murry','Aishwarya','Usha','SOMANATH','Menukul Kin','Shalini','fathima','Priyanka','Suhasini','Dhanalakshmi Koneti','Padma','Vishal','Mallikarjun B Kawali','Suhaib','Shravan','Ashima','Venkatesha B','Aditya','Vimeü Miachieo','NINGAPPA SANNAKKI','Rajeshwari','Mainak','Chakjemmenba','A Aotula Lemtur','Basavaraj','Kevisano maria','JAFFAR SHAREEF','HONNAPPA KAMBAR','Zehra','Saraswati','Tiarenla Imchen','Dr.Gururaj Agnihotri','Archana','Ruth kemp','Zuchobeni','JUBRAIL MULLA','RAJAMAHAMMAD KODIHAL','Hardik','Chennamsetty naveen','Shruthi','Bijano','Ajanthung','Basavaraj S','Sonal','Rohan','Smt H L Kademani','smt susheela','Suresh Walikar','Balakrishna','Imtilemla','Naveen','Praveen rao','Kunal','Merentula Imsong','Nzanmongi','Maruti','Bharati','Vijay','Channaveerayyaa D Hiremath','SUREKHA','thippeswmy kv','ashwin','Gauri','Imnawapang','Akash','Mahadev','Nikhil Gehlot','vijay','Bhimashankar','RAJAKUMAR PATIL','SALOMI L Achumi','Shameem Banu','Pooja','Modi','Vishalaxi','Zachamo','Rajshekhar S Melashetty BRP','Anupriya','Annapurna','Ramesh','Chetan','Lakshmi','Mister Manusch','Master Singh','Rigi','Anisha','Sanjeev','Athrongla','Ramabai','BASAVARAJ','Ram','Arun','Raj','Kumar','Channakeshava VP','Anilkumar','Mohit','Ramappa Navhi','Shesh','Mhashekhoto','Yallappa','Vibha Rawat','Murali','Shaila','Amoghavarsh','Mohit 782','sudhakar','smt Siddamma d hebbal','Vinay','Stephen','Sreeja','Bhavya','Roopa M','Kivitoli sumi','Prateek Agarwal','Nidhi','Pangzungmar','Bendangla Ao','Nithya','Imlisenla Longchar','Lorem','Shivakumar Parashatti','Zechano Z Khuvung','Mansa','Niveditha','SANJAY KUMAR'] + +company_slug = 'shikshalokam' + +company = Company.objects.get(slug=company_slug) +company_chats = CompanyChat.objects.filter( + ( + Q(sender__company=company) & Q(sender__first_name__in=filtered_names) + ) | + ( + Q(receiver__company=company) & Q(receiver__first_name__in=filtered_names) + ) +) + +sessions = company_chats.values_list('session', flat=True).distinct() +print(sessions) diff --git a/shikshalokam/serializer/__init__.py b/shikshalokam/serializer/__init__.py new file mode 100644 index 0000000..10961b3 --- /dev/null +++ b/shikshalokam/serializer/__init__.py @@ -0,0 +1 @@ +from .base_serializer import * diff --git a/shikshalokam/serializer/base_serializer.py b/shikshalokam/serializer/base_serializer.py new file mode 100644 index 0000000..3aeaded --- /dev/null +++ b/shikshalokam/serializer/base_serializer.py @@ -0,0 +1,84 @@ +import json + +from rest_framework import serializers + +from chatbot.serializer.profile_serializer import ProfileSerializer +from shikshalokam.models.project_models import Project, Task, Evidence, LearningResources +from shikshalokam.models.template_models import Category, ProjectTemplate +from shikshalokam.models.wishlist_model import ProjectWishlist + + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = '__all__' + + +class ProjectTemplateSerializer(serializers.ModelSerializer): + category = CategorySerializer(read_only=True) + class Meta: + model = ProjectTemplate + fields = '__all__' + + +class LearningResourceSerializer(serializers.ModelSerializer): + class Meta: + model = LearningResources + fields = '__all__' + + +class TaskSerializer(serializers.ModelSerializer): + class Meta: + model = Task + fields = '__all__' + + +class EvidenceSerializer(serializers.ModelSerializer): + class Meta: + model = Evidence + fields = '__all__' + + +class ProjectWishlistSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectWishlist + fields = '__all__' + + +class ProjectSerializer(serializers.ModelSerializer): + project_template = ProjectTemplateSerializer(read_only=True) + task = TaskSerializer(many=True, read_only=True) + author = ProfileSerializer(read_only=True) + categories = serializers.ListField(child=serializers.JSONField(), required=False) + recommended_for = serializers.ListField(child=serializers.JSONField(), required=False) + evidence = EvidenceSerializer(many=True, read_only=True) + learning_resource = LearningResourceSerializer(many=True, read_only=True) + + def to_representation(self, instance): + representation = super().to_representation(instance) + + user = self.context.get('author') + print("author: ", user) + print("project: ", instance) + if user and instance: + in_wishlist = ProjectWishlist.objects.filter(author=user, project=instance).exists() + print("wishlist: ", in_wishlist) + representation['wishlist'] = in_wishlist + + for field in ['categories', 'recommended_for', 'keywords']: + try: + representation[field] = json.loads(getattr(instance, field)) if getattr(instance, field) else [] + except json.JSONDecodeError: + representation[field] = [] + return representation + + def to_internal_value(self, data): + internal_value = super().to_internal_value(data) + for field in ['categories', 'recommended_for', 'keywords']: + if field in data: + internal_value[field] = json.dumps(data[field]) + return internal_value + + class Meta: + model = Project + fields = '__all__' diff --git a/shikshalokam/tests.py b/shikshalokam/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shikshalokam/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shikshalokam/urls.py b/shikshalokam/urls.py new file mode 100644 index 0000000..6922e0f --- /dev/null +++ b/shikshalokam/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from shikshalokam.views.project_views import ProjectListCreateView, duplicate_project_view, project_ingestion_view +from shikshalokam.views.story_views import create_story_from_project_view +from shikshalokam.views.wishlist_views import wishlist_project_view +from shikshalokam.views.mitra_views import paraphrase_view, generate_objectives_view, validate_objectives_view, validate_actions_view, generate_action_list_view, generate_title_view, validate_title_view, update_project_status_view +from shikshalokam.views.profile_views import read_elevate_profile + +app_name = "shikshalokam" + +urlpatterns = [ + path('create-story/', create_story_from_project_view, name='create story'), + path('project/', ProjectListCreateView.as_view(), name='project-list-create'), + path('start-project/', duplicate_project_view, name='start-project'), + path('ingest-data/', project_ingestion_view, name='ingest-data'), + path('wishlist-project/', wishlist_project_view, name='wishlist-project'), + path('paraphrase/', paraphrase_view, name='paraphrase'), + path('generate-objective/', generate_objectives_view, name='generate-objectives'), + path('validate-objective/', validate_objectives_view, name='validate-objectives'), + path('validate-actions/', validate_actions_view, name='validate-actions'), + path('generate-action-list/', generate_action_list_view, name='generate-action-list'), + path('generate-title/', generate_title_view, name='generate-title'), + path('validate-title/', validate_title_view, name='validate-title'), + path('update-project-status/', update_project_status_view, name='update-project-status'), + path('read-elevate-profile/', read_elevate_profile, name='read-elevate-profile'), + +] diff --git a/shikshalokam/utils/__init__.py b/shikshalokam/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shikshalokam/utils/action_list/action_parser.py b/shikshalokam/utils/action_list/action_parser.py new file mode 100644 index 0000000..d3be952 --- /dev/null +++ b/shikshalokam/utils/action_list/action_parser.py @@ -0,0 +1,242 @@ +from shikshalokam.utils.chunks_utils import normalize_source_id +import json_repair +import json +import logging + +logger = logging.getLogger('django') + + +def unwrap_tool_values(obj): + """Recursively unwrap tool schema values.""" + if isinstance(obj, dict): + # Tool schema leaf + if "value" in obj and "type" in obj and len(obj) == 2: + return unwrap_tool_values(obj["value"]) + + return {k: unwrap_tool_values(v) for k, v in obj.items()} + + if isinstance(obj, list): + return [unwrap_tool_values(v) for v in obj] + + return obj + + +def normalize_action_steps(action_steps): + """ + Normalizes actionSteps. + """ + if not action_steps: + raise ValueError("EMPTY_ACTION_STEPS") + + if isinstance(action_steps, str): + try: + action_steps = json.loads(action_steps) + except Exception: + try: + action_steps = json_repair.repair_json( + action_steps, return_objects=True + ) + except Exception: + raise ValueError("ACTION_STEPS_MALFORMED_JSON") + + return action_steps + + +def validate_action_steps(action_steps): + """ + Validates normalized actionSteps. + """ + if not isinstance(action_steps, list): + raise ValueError("ACTION_STEPS_NOT_LIST") + + if not action_steps: + raise ValueError("EMPTY_ACTION_STEPS") + + for i, step in enumerate(action_steps): + if not isinstance(step, dict): + raise ValueError(f"INVALID_STEP_OBJECT_{i}") + + step_text = step.get("step", "").strip() + + if not isinstance(step_text, str) or not step_text: + raise ValueError(f"EMPTY_STEP_TEXT_{i}") + if step_text.lower() in ("type", "value"): + raise ValueError(f"INVALID_STEP_TEXT_{i}") + + +def parse_llm_action_response(response, filtered_chunks): + """ + Parse LLM response into structured action list with source validation. + """ + try: + print("llm response: ", response) + if not response or not isinstance(response, dict): + raise ValueError("INVALID_LLM_RESPONSE") + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + response = tool_input + break + + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data: + extracted_data = unwrap_tool_values(extracted_data) + response = extracted_data + + print("\nextracted_data: ", extracted_data) + logger.info(f"extracted_data: {extracted_data}") + + action_plans = ( + response.get('action_plans') or + response.get('action_plan') or + response.get('action_list') or + response.get('actions') or + [] + ) + + if not action_plans: + if response.get('plan_name') and response.get('actionSteps'): + action_plans = [response] + elif response.get('plan_name') and (response.get('action_steps') or response.get('steps')): + action_plans = [response] + + if isinstance(action_plans, dict): + if 'value' in action_plans: + action_plans = action_plans['value'] + elif 'items' in action_plans: + action_plans = action_plans['items'] + + if isinstance(action_plans, str): + try: + action_plans = json_repair.repair_json(action_plans, return_objects=True) + except: + try: + action_plans = json.loads(action_plans) + except: + action_plans = [] + + if not isinstance(action_plans, list): + action_plans = [action_plans] if action_plans else [] + + action_list = [] + + # Create set of normalized source IDs for validation + valid_source_ids = set() + for chunk in filtered_chunks: + normalized_id = normalize_source_id(chunk.get('source_id')) + if normalized_id: + valid_source_ids.add(normalized_id) + + print(f"Valid source IDs (normalized): {valid_source_ids}") + + for plan in action_plans: + if isinstance(plan, dict): + plan_name = plan.get('plan_name', '') + duration_weeks = (plan.get('duration_weeks') or + plan.get('overall_duration_weeks') or + plan.get('duration', 13)) + + action_steps_data = (plan.get('actionSteps', []) or + plan.get('action_steps', []) or + plan.get('steps', [])) + + action_steps_data = normalize_action_steps(action_steps_data) + validate_action_steps(action_steps_data) + + processed_steps = [] + all_source_ids = set() + all_sources = [] + + for step_data in action_steps_data: + if isinstance(step_data, dict): + step_text = step_data.get('step', step_data.get('text', '')) + + sources = step_data.get('sources', []) + if sources is None: + sources = [] + if isinstance(sources, str): + if sources.strip() in ("[]", ""): + sources = [] + else: + try: + sources = json.loads(sources) + except: + sources = [] + if not isinstance(sources, list): + sources = [sources] + + reason = step_data.get('reason', '') + + has_sources = bool(sources) + has_valid_sources = False + step_source_ids = [] + step_sources = [] + + for src in sources: + if isinstance(src, dict): + raw_source_id = src.get('source_id') + normalized_id = normalize_source_id(raw_source_id) + highlight_text = src.get('highlight_text', '') + confidence_score = src.get('confidence_score', 0) + + if normalized_id and normalized_id in valid_source_ids: + original_id = None + has_valid_sources = True + for chunk in filtered_chunks: + if normalize_source_id(chunk.get('source_id')) == normalized_id: + original_id = chunk.get('source_id') + break + + if original_id is not None: + step_source_ids.append(original_id) + all_source_ids.add(original_id) + if confidence_score in [5, "5"]: + step_sources.append({ + 'source_id': original_id, + 'highlight_text': highlight_text + }) + else: + print( + f"Warning: source_id '{raw_source_id}' (normalized: '{normalized_id}') not found in valid chunks") + + step_text = step_text.strip() + + if step_text: + processed_steps.append({ + 'step': step_text, + 'sources': step_sources, + 'source_ids': step_source_ids, + 'reason': reason, + 'is_evidence_optional': not has_sources + }) + + all_sources.extend(step_sources) + + elif isinstance(step_data, str): + processed_steps.append({ + 'step': step_data, + 'sources': [], + 'source_ids': [], + 'reason': '' + }) + + if processed_steps: + action_list.append({ + 'plan_name': plan_name, + 'duration_weeks': duration_weeks, + 'actionSteps': processed_steps, + 'all_source_ids': list(all_source_ids), + 'all_sources': all_sources + }) + + print(f"\nParsed {len(action_list)} action plans from response") + return action_list + + except ValueError as e: + logger.error(f"ActionSteps validation failed: {str(e)}") + raise diff --git a/shikshalokam/utils/action_list/action_processor.py b/shikshalokam/utils/action_list/action_processor.py new file mode 100644 index 0000000..84e948d --- /dev/null +++ b/shikshalokam/utils/action_list/action_processor.py @@ -0,0 +1,217 @@ +from shikshalokam.utils.chunks_utils import normalize_source_id + + +def post_process_actions_with_source(action_list, filtered_chunks, chunks_response): + """ + Enrich action list with complete source information including chunks, scores, and metadata. + """ + try: + if not action_list or not isinstance(action_list, list): + raise ValueError("Error in LLM returned action list") + + source_id_to_score = {} + for chunk in filtered_chunks: + source_id = chunk.get('source_id') + if source_id is not None: + source_id_to_score[source_id] = chunk['relevance_score'] + normalized = normalize_source_id(source_id) + if normalized: + source_id_to_score[normalized] = chunk['relevance_score'] + + source_map = {} + if chunks_response and chunks_response.get("results"): + try: + for result in chunks_response["results"]: + if not isinstance(result, dict): + print(f"Skipping invalid result in post_process: {result}") + continue + + source_id = result.get('source_id', '') or result.get('metadata', {}).get('source_id', '') + + if not source_id: + print(f"Skipping result without source_id: {result}") + continue + + chunk_text = result.get('text', '') + metadata = result.get('metadata', {}) + description = metadata.get('summary', '') + title = metadata.get('title', '') or metadata.get('TITLE', '') + url = metadata.get('url', '') + organization_slug = metadata.get('company', '') + highlight_text = result.get('highlight_text', '') + + organization_dict = {} + if organization_slug: + try: + from chatbot.models import Company + company = Company.objects.filter(slug=organization_slug).first() + if company: + organization_dict = { + 'name': company.name, + 'slug': company.slug + } + else: + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + except Exception as org_error: + print(f"Error fetching company for slug '{organization_slug}': {str(org_error)}") + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + + chunk_data = { + 'highlight_text': highlight_text, + 'chunk': chunk_text + } + + if source_id not in source_map: + source_entry = { + 'source_id': source_id, + 'description': description, + 'title': title, + 'url': url, + 'organization': organization_dict, + 'chunks': [chunk_data] + } + source_map[source_id] = source_entry + + normalized_id = normalize_source_id(source_id) + if normalized_id and normalized_id != source_id: + source_map[normalized_id] = source_entry + else: + source_map[source_id]['chunks'].append(chunk_data) + + if not source_map[source_id]['description'] and description: + source_map[source_id]['description'] = description + if not source_map[source_id]['title'] and title: + source_map[source_id]['title'] = title + if not source_map[source_id]['url'] and url: + source_map[source_id]['url'] = url + if not source_map[source_id]['organization'] and organization_dict: + source_map[source_id]['organization'] = organization_dict + + except Exception as map_error: + print(f"Error creating source_map: {str(map_error)}") + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'message': f'Error mapping source data: {str(map_error)}' + } + + processed_actions = [] + for action_plan in action_list: + try: + if not isinstance(action_plan, dict): + print(f"Skipping invalid action plan: {action_plan}") + continue + + processed_steps = [] + for step_data in action_plan.get('actionSteps', []): + if isinstance(step_data, dict): + step_sources = [] + for source_id in step_data.get('source_ids', []): + score = source_id_to_score.get(source_id, 0) + if score == 0: + normalized_id = normalize_source_id(source_id) + score = source_id_to_score.get(normalized_id, 0) + + source_info = source_map.get(source_id) + if not source_info: + normalized_id = normalize_source_id(source_id) + source_info = source_map.get(normalized_id) + + if not source_info: + source_info = { + 'source_id': source_id, + 'chunks': [], + 'description': '', + 'title': '', + 'url': '', + 'organization': {} + } + + highlight_texts = [] + for src in step_data.get("sources", []): + src_id_normalized = normalize_source_id(src.get("source_id")) + source_id_normalized = normalize_source_id(source_id) + if src_id_normalized == source_id_normalized and src.get("highlight_text"): + highlight_texts.append(src.get("highlight_text")) + + chunks_with_highlights = [] + for i, chunk_data in enumerate(source_info.get('chunks', [])): + chunk_entry = { + 'chunk': chunk_data.get('chunk', ''), + 'highlight_text': chunk_data.get('highlight_text', '') + } + if i < len(highlight_texts): + chunk_entry['highlight_text'] = highlight_texts[i] + chunks_with_highlights.append(chunk_entry) + + step_sources.append({ + 'source_id': source_id, + 'score': score, + 'chunks': chunks_with_highlights, + 'description': source_info.get('description', ''), + 'title': source_info.get('title', ''), + 'url': source_info.get('url', ''), + 'organization': source_info.get('organization', {}), + 'chunk_count': len(chunks_with_highlights) + }) + + processed_steps.append({ + 'step': step_data.get('step', ''), + 'reason': step_data.get('reason', ''), + 'sources': step_sources + }) + elif isinstance(step_data, str): + processed_steps.append({ + 'step': step_data, + 'reason': '', + 'sources': [] + }) + + all_source_ids = action_plan.get('all_source_ids', []) + total_score = 0 + for sid in all_source_ids: + score = source_id_to_score.get(sid, 0) + if score == 0: + normalized_id = normalize_source_id(sid) + score = source_id_to_score.get(normalized_id, 0) + total_score += score + + avg_score = total_score / len(all_source_ids) if all_source_ids else 0 + + processed_action = { + 'plan_name': action_plan.get('plan_name', ''), + 'duration_weeks': action_plan.get('duration_weeks', 3), + 'actionSteps': processed_steps, + 'score': avg_score, + 'source_count': len(all_source_ids) + } + processed_actions.append(processed_action) + + except Exception as action_error: + print(f"Error processing action: {str(action_error)}") + continue + + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': processed_actions, + 'message': f'Successfully processed {len(processed_actions)} actions with source information' + } + + except Exception as e: + print(f"Unexpected error in post_process_actions_with_source: {str(e)}") + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/action_list/action_steps_utils.py b/shikshalokam/utils/action_list/action_steps_utils.py new file mode 100644 index 0000000..65ed7a8 --- /dev/null +++ b/shikshalokam/utils/action_list/action_steps_utils.py @@ -0,0 +1,445 @@ +from asgiref.sync import sync_to_async +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_response_api, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from chatbot.utils.chat_query_handler import query_text_search +from chatbot.utils.story_llama_utils import translate_field +from shikshalokam.utils.action_list.action_parser import parse_llm_action_response +from shikshalokam.utils.action_list.action_validator import parse_validator_response, validate_and_fix_action_list +from shikshalokam.utils.chunks_utils import validate_inputs, filter_and_sort_chunks, prepare_chunks_for_template, render_template_with_context +import asyncio +import json +import json_repair +import logging + +logger = logging.getLogger('django') + + +async def load_bot(route: str): + from types import SimpleNamespace + data = await sync_to_async( + CompanyBot.objects.values( + "provider", "context", "end_context", "tag_context", + "tool_context", "llm_model", "bot_temperature", + "filter_score", "max_token" + ).get + )(route=route) + return SimpleNamespace(**data) + + +async def generate_action_list_parallel(query, objectives, company_bot, language, voice_provider, max_concurrency: int = 3): + """Generate action lists for multiple objectives concurrently. + + NOTE: `max_concurrency` is implemented via a per-call semaphore so this function + can be safely invoked from sync contexts using `async_to_sync` (no cross-event-loop + semaphore binding). + """ + combiner_bot = None + try: + combiner_bot = await load_bot("/action_list_combiner") + except Exception as e: + pass + + if combiner_bot is None: + objective_str = "" + for index, objective in enumerate(objectives): + objective_str += f"{index + 1}. {objective}\n" + return await sync_to_async(generate_action_list_utils)(query, objective_str, company_bot, language, voice_provider) + + sem = asyncio.Semaphore(max_concurrency) + + async def _one(objective): + async with sem: + return await asyncio.to_thread( + generate_action_list_utils, + query, + objective, + company_bot, + language, + voice_provider, + ) + + tasks = [_one(objective) for objective in objectives] + results = await asyncio.gather(*tasks, return_exceptions=True) + + logger.info(f"parallel_results: {json.dumps(results)}") + + plans_list = [] + + master_plan_name = [] + total_duration = 0 + action_steps = [] + chunks_response_master = None + filtered_chunks_master = [] + + step_id_to_actionstep = {} + + for index, result in enumerate(results): + if result.get('status') != 'ok': + logger.error( + "[generate_action_list_view] Generation failed with status: %s, message: %s", + result.get('status'), + result.get('message') + ) + raise ValueError(f"Generation failed with status: {result.get('status')}, message: {result.get('message')}") + + if result.get("filtered_chunks", []): + filtered_chunks_master.extend(result.get("filtered_chunks", [])) + + if chunks_response_master is None: + chunks_response_master = result.get("chunks_response", None) + + elif chunks_response_master and chunks_response_master.get("results", []): + chunks_response_master.get("results", []).extend(result.get("chunks_response", {}).get("results", [])) + + action_list = result.get("action_list", []) + for _i, action in enumerate(action_list): + duration_in_weeks = action.get('duration_weeks') + if isinstance(duration_in_weeks, str): + try: + duration_in_weeks = int(duration_in_weeks) + except ValueError: + logger.warning(f"Invalid duration_weeks value: {duration_in_weeks}") + duration_in_weeks = 0 + if isinstance(duration_in_weeks, int): + total_duration += duration_in_weeks + plan_name = action.get('plan_name') + action_steps_arr = [] + for i, step in enumerate(action.get('actionSteps', [])): + step_id = f"{index}_{i}" + action_steps_arr.append({"step": step.get('step'), "step_id": step_id}) + step_id_to_actionstep[step_id] = step + plans_list.append({ + "plan_name": plan_name, + "actionSteps": action_steps_arr, + }) + master_plan_name.append(plan_name) + + + master_plan_name = ' and '.join(list(set(master_plan_name))) + + logger.info(f"{json.dumps(plans_list, indent=4)}, plans_list") + user_input = combiner_bot.tag_context + + user_input = f"{user_input}\n\n{json.dumps(plans_list, indent=2)}" + if combiner_bot and combiner_bot.provider == LLMProvider.OPENAI: + messages = [ + { + 'role': 'user', + 'content': user_input + } + ] + context = combiner_bot.context + context += f"\n{combiner_bot.end_context}" if combiner_bot.end_context else "" + system_prompt = [{"role": "system", "content": context}] + + tools = None + tool_choice = None + try: + tool_context = json_repair.repair_json(combiner_bot.tool_context, return_objects=True) + if tool_context: + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + + logger.info("-----------------OPENAI COMBINER---------------------------------",) + logger.info(f"openai system_prompt: {system_prompt}") + logger.info(f"openai messages: {messages}") + response = handle_openai_model( + messages=messages, system_prompt=system_prompt, max_token=combiner_bot.max_token, + temperature=combiner_bot.bot_temperature, company_bot=combiner_bot, + top_p=combiner_bot.filter_score if combiner_bot.filter_score else None, + tool_choice=tool_choice, tools=tools, stream=False, is_json_response=True + ) + + else: + user_message = [{ + 'role': 'user', + 'content': [{'text': user_input}] + }] + + system_prompt = [{'text': combiner_bot.context}] + tool_context = combiner_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=user_message, model_name=combiner_bot.llm_model, + temperature=combiner_bot.bot_temperature, max_token=combiner_bot.max_token, + company_bot=combiner_bot, tools=tool_context, top_p=combiner_bot.filter_score, is_json_response=True + ) + + if not response or not isinstance(response, dict): + logger.info("Invalid validation response from LLM: %s", response) + + parsed_response = parse_validator_response(response) + + logger.info(f"parsed_response: {json.dumps(parsed_response)}") + + parsed_response = parsed_response.get("actionSteps", []) + + if isinstance(parsed_response, str): + parsed_response = json_repair.repair_json(parsed_response, return_objects=True) + + if not isinstance(parsed_response, list): + logger.error("Invalid response from LLM, `actionSteps` is not a list: %s", parsed_response) + raise ValueError("Invalid response from LLM, `actionSteps` is not a list") + + for index, action_step in enumerate(parsed_response): + + step_ids = [] + if isinstance(action_step.get("step_id"), str): + action_step["step_id"] = json_repair.repair_json(action_step.get("step_id"), return_objects=True) + + if isinstance(action_step.get("step_id"), list): + step_ids = action_step.get("step_id") + + sources_master = [] + source_ids_master = [] + for id in step_ids: + if id in step_id_to_actionstep: + if isinstance(step_id_to_actionstep[id].get("sources"), list): + sources_master.extend(step_id_to_actionstep[id].get("sources")) + + elif isinstance(step_id_to_actionstep[id].get("sources"), str): + sources_master = sources_master.extend(json_repair.repair_json(step_id_to_actionstep[id].get("sources"), return_objects=True)) + + source_ids_master.extend(step_id_to_actionstep[id].get("source_ids")) + + action_steps.append({ + "step": action_step.get("step"), + "reason": action_step.get("reason", ""), + "sources": sources_master, + "source_ids": source_ids_master, + }) + + return { + "status": "ok", + "message": "Successfully generated action steps", + "action_list": [ + { + "plan_name": master_plan_name, + "duration_weeks": total_duration, + "actionSteps": action_steps + } + ], + "chunks_response": chunks_response_master, + "filtered_chunks": filtered_chunks_master + } + + +def generate_action_list_utils(query, objective_text, company_bot, language, voice_provider, plans=[]): + try: + if isinstance(objective_text, list): + final_objective_text = "" + for index in range(objective_text): + final_objective_text += f"{index + 1}. {objective_text[index]}\n" + + objective_text = final_objective_text + + if language != 'en': + logger.info(f"[generate_action_list_view] Translating inputs from {language} to English") + query = translate_field( + voice_provider=voice_provider, message_body=query, source_language=language, + target_language='en' + ) + objective_text = translate_field( + voice_provider=voice_provider, message_body=objective_text, source_language=language, + target_language='en' + ) + logger.info(f"[generate_action_list_view] Translated problem statement: {query}") + logger.info(f"[generate_action_list_view] Translated objective: {objective_text}") + + required_attrs = ['top_k', 'filter_score', 'context', 'tag_context', 'llm_model', 'bot_temperature', 'max_token'] + + if not query or not isinstance(query, str): + return { + 'status': 'error', + 'status_code': 400, + 'action_list': [], + 'chunks_response': None, + 'message': 'Invalid query: must be a non-empty string' + } + + validation = validate_inputs(objective_text, company_bot, required_attrs) + if not validation['valid']: + return { + 'status': 'error', + 'status_code': 400, + 'action_list': [], + 'chunks_response': None, + 'message': validation['message'] + } + + try: + chunks_response = query_text_search( + query=objective_text, priority="P1", limit=company_bot.top_k + ) + + if chunks_response.get('error'): + print(f"Error while fetching chunks: {chunks_response.get('error')}") + logger.info(f"Error while fetching chunks: {chunks_response.get('error')}") + + + except Exception as db_error: + print(f"Error while fetching chunks: {db_error}") + logger.info(f"Error while fetching chunks: {db_error}") + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': None, + 'message': f'Database query failed: {str(db_error)}' + } + + filtered_chunks = [] + if chunks_response and chunks_response.get("results"): + filtered_chunks = filter_and_sort_chunks( + chunks_response, company_bot.filter_score, company_bot.top_k + ) + + try: + chunks_data = prepare_chunks_for_template(filtered_chunks) + + context_data = { + 'user_query': query, + 'objective': objective_text, + 'chunks': chunks_data, + 'total_chunks': len(chunks_data), + "plans": plans + } + + rendered_content = render_template_with_context( + company_bot.tag_context, context_data + ) + + if company_bot and company_bot.provider == LLMProvider.OPENAI: + messages = [ + { + 'role': 'user', + 'content': rendered_content + } + ] + context = company_bot.context or "Generate action plans." + context += f"\n{company_bot.end_context}" if company_bot.end_context else "" + system_prompt = [{"role": "system", "content": context}] + + tools = None + tool_choice = None + try: + tool_context = json_repair.repair_json(company_bot.tool_context, return_objects=True) + if tool_context: + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + + logger.info("-----------------OPENAI PARALLEL---------------------------------", ) + logger.info(f"openai system_prompt: {system_prompt}") + logger.info(f"openai messages: {messages}") + response = handle_openai_model( + messages=messages, system_prompt=system_prompt, max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice=tool_choice, tools=tools, stream=False, is_json_response=True + ) + + else: + + messages = [{ + 'role': 'user', + 'content': [{'text': rendered_content}] + }] + + system_prompt = [{'text': company_bot.context}] if company_bot.context else [ + {'text': 'Generate action plans.'}] + + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + try: + validate_bot = CompanyBot.objects.filter(route='/validate_action_list').first() + if validate_bot: + response = validate_and_fix_action_list( + messages=messages, response_json=response, company_bot=validate_bot + ) + logger.info("Validation applied using validate_bot") + else: + logger.info("No validate_bot found with route='/validate_action_list', skipping validation") + + except CompanyBot.DoesNotExist: + logger.error("validate_bot not found, proceeding without validation") + except Exception as validation_error: + logger.error(f"Validation failed: {validation_error}, proceeding with original response") + + if not response: + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': chunks_response, + 'message': 'Invalid response from LLM' + } + + action_list = parse_llm_action_response(response, filtered_chunks) + logger.info(f"action_list: {action_list}") + if not action_list: + raise ValueError("LLM returned empty action list") + + except ValueError as e: + logger.error("Invalid response from LLM: %s", e) + return { + 'status': 'error', + 'status_code': 422, + 'action_list': [], + 'chunks_response': chunks_response, + 'message': str(e) + } + + except Exception as llm_error: + logger.error("Error while fetching chunks: %s", llm_error) + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': chunks_response, + 'message': f'Error generating actions: {str(llm_error)}' + } + + total_results = chunks_response.get('total_results', 0) + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': action_list, + 'filtered_chunks': filtered_chunks, + 'total_actions': len(action_list), + 'total_chunks_used': len(filtered_chunks), + 'total_chunks_found': total_results, + 'total_results': total_results, + 'chunks_response': chunks_response, + 'message': f'Successfully generated {len(action_list)} action plans' + } + + except Exception as e: + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'total_actions': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': None, + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/action_list/action_validator.py b/shikshalokam/utils/action_list/action_validator.py new file mode 100644 index 0000000..60a1e54 --- /dev/null +++ b/shikshalokam/utils/action_list/action_validator.py @@ -0,0 +1,130 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import LLMProvider +from shikshalokam.utils.action_list.action_parser import unwrap_tool_values +from shikshalokam.utils.chunks_utils import render_template_with_context +import logging +import json_repair + +logger = logging.getLogger('django') + + +def validate_and_fix_action_list(messages, response_json, company_bot): + """ + Validate and fix action list using end_context prompt. + """ + context_data = { + 'response': response_json, + } + + rendered_content = render_template_with_context( + company_bot.tag_context, context_data + ) + + prompt = f""" + {company_bot.context} + {context_data} + {rendered_content} + """ + + if company_bot and company_bot.provider == LLMProvider.OPENAI: + system_prompt = [{"role": "system", "content": prompt}] + + tools = None + tool_choice = None + try: + tool_context = json_repair.repair_json(company_bot.tool_context, return_objects=True) + if tool_context: + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + + logger.info("-----------------OPENAI PARALLEL---------------------------------", ) + logger.info(f"openai system_prompt: {system_prompt}") + logger.info(f"openai messages: {messages}") + validation_response = handle_openai_model( + messages=messages, system_prompt=system_prompt, max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice=tool_choice, tools=tools, stream=False, is_json_response=True + ) + + else: + + system_prompt = [{ + "text": prompt + }] + + tool_context = company_bot.tool_context + if tool_context: + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + validation_response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + logger.info(f"validation_response: {validation_response}") + + if not validation_response or not isinstance(validation_response, dict): + logger.info("Invalid validation response from LLM") + return response_json + + parsed_response = parse_validator_response(validation_response) + + if not parsed_response: + logger.info("Failed to parse validation response") + return response_json + + logger.info(f"Parsed validation response successfully") + return parsed_response + + +def parse_validator_response(validation_response): + """ + Parse validator LLM response into the expected format. + """ + try: + logger.info(f"validation_response: {validation_response}") + + if not validation_response or not isinstance(validation_response, dict): + logger.info("Invalid validation response, returning None") + return None + + if 'output' in validation_response: + content = validation_response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + validation_response = tool_input + break + + extracted_data = validation_response.pop("parameters", validation_response.pop("input", None)) + if extracted_data: + extracted_data = unwrap_tool_values(extracted_data) + validation_response = extracted_data + + logger.info(f"extracted validation data: {extracted_data}") + + final_answer = validation_response.get('final_answer') + if final_answer: + if isinstance(final_answer, dict) and 'value' in final_answer: + final_answer = final_answer['value'] + validation_response = final_answer + + if not isinstance(validation_response, dict): + logger.info("Validation response is not a dict after extraction") + return None + + return validation_response + + except Exception as e: + logger.error(f"Error parsing validation response: {str(e)}") + import traceback + traceback.print_exc() + return None diff --git a/shikshalokam/utils/action_steps_utils.py b/shikshalokam/utils/action_steps_utils.py new file mode 100644 index 0000000..54b01f6 --- /dev/null +++ b/shikshalokam/utils/action_steps_utils.py @@ -0,0 +1,472 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model +from shikshalokam.utils.chunks_utils import validate_inputs, filter_and_sort_chunks, prepare_chunks_for_template, \ + render_template_with_context +import json_repair +import json + + +def generate_action_list_utils(query, objective_text, company_bot): + try: + from chatbot.utils.chat_query_handler import query_text_search + + required_attrs = ['top_k', 'filter_score', 'context', 'tag_context', 'llm_model', 'bot_temperature', + 'max_token'] + + if not query or not isinstance(query, str): + return { + 'status': 'error', + 'status_code': 400, + 'action_list': [], + 'chunks_response': None, + 'message': 'Invalid query: must be a non-empty string' + } + + validation = validate_inputs(objective_text, company_bot, required_attrs) + if not validation['valid']: + return { + 'status': 'error', + 'status_code': 400, + 'action_list': [], + 'chunks_response': None, + 'message': validation['message'] + } + + try: + chunks_response = query_text_search( + query=objective_text, priority="P1", limit=company_bot.top_k + ) + + if chunks_response.get('error'): + return { + 'status': 'error', + 'status_code': chunks_response.get('status_code', 500), + 'action_list': [], + 'chunks_response': None, + 'message': chunks_response.get('message', 'API request failed') + } + + except Exception as db_error: + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': None, + 'message': f'Database query failed: {str(db_error)}' + } + + if not chunks_response or not chunks_response.get("results"): + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': [], + 'total_actions': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': chunks_response, + 'message': 'No chunks found from text-search API' + } + + filtered_chunks = filter_and_sort_chunks( + chunks_response, company_bot.filter_score, company_bot.top_k + ) + + if not filtered_chunks: + total_chunks = len(chunks_response.get("results", [])) + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': [], + 'total_actions': 0, + 'total_chunks_used': 0, + 'total_chunks_found': total_chunks, + 'total_results': total_chunks, + 'chunks_response': chunks_response, + 'message': f'No chunks met filter criteria' + } + + try: + chunks_data = prepare_chunks_for_template(filtered_chunks) + + context_data = { + 'user_query': query, + 'objective': objective_text, + 'chunks': chunks_data, + 'total_chunks': len(chunks_data) + } + + rendered_content = render_template_with_context( + company_bot.tag_context, context_data + ) + + messages = [{ + 'role': 'user', + 'content': [{'text': rendered_content}] + }] + + system_prompt = [{'text': company_bot.context}] if company_bot.context else [ + {'text': 'Generate action plans.'}] + + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + if not response: + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': chunks_response, + 'message': 'Invalid response from LLM' + } + + action_list = parse_llm_action_response(response, filtered_chunks) + + except Exception as llm_error: + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'chunks_response': chunks_response, + 'message': f'Error generating actions: {str(llm_error)}' + } + + total_results = chunks_response.get('total_results', 0) + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': action_list, + 'filtered_chunks': filtered_chunks, + 'total_actions': len(action_list), + 'total_chunks_used': len(filtered_chunks), + 'total_chunks_found': total_results, + 'total_results': total_results, + 'chunks_response': chunks_response, + 'message': f'Successfully generated {len(action_list)} action plans' + } + + except Exception as e: + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'total_actions': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': None, + 'message': f'Internal server error: {str(e)}' + } + + +def parse_llm_action_response(response, filtered_chunks): + print("llm response: ", response) + if not response or not isinstance(response, dict): + return [] + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + response = tool_input + break + + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response = extracted_data + + print("\nextracted_data: ", extracted_data) + + action_plans = ( + response.get('action_plans') or + response.get('action_plan') or + response.get('action_list') or + response.get('actions') or + [] + ) + + if isinstance(action_plans, dict): + if 'value' in action_plans: + action_plans = action_plans['value'] + elif 'items' in action_plans: + action_plans = action_plans['items'] + + if isinstance(action_plans, str): + try: + action_plans = json_repair.repair_json(action_plans, return_objects=True) + except: + try: + action_plans = json.loads(action_plans) + except: + action_plans = [] + + if not isinstance(action_plans, list): + action_plans = [action_plans] if action_plans else [] + + action_list = [] + valid_source_ids = {chunk['source_id'] for chunk in filtered_chunks} + + for plan in action_plans: + if isinstance(plan, dict): + plan_name = plan.get('plan_name', '') + duration_weeks = plan.get('duration_weeks', plan.get('duration', 3)) + action_steps_data = plan.get('actionSteps', []) or plan.get('action_steps', []) or plan.get('steps', []) + + processed_steps = [] + all_source_ids = set() + all_sources = [] + + for step_data in action_steps_data: + if isinstance(step_data, dict): + step_text = step_data.get('step', step_data.get('text', '')) + sources = step_data.get('sources', []) + reason = step_data.get('reason', '') + + step_source_ids = [] + step_sources = [] + + for src in sources: + if isinstance(src, dict): + source_id = src.get('source_id') + highlight_text = src.get('highlight_text', '') + + if source_id and source_id in valid_source_ids: + step_source_ids.append(source_id) + all_source_ids.add(source_id) + step_sources.append({ + 'source_id': source_id, + 'highlight_text': highlight_text + }) + + processed_steps.append({ + 'step': step_text, + 'sources': step_sources, + 'source_ids': step_source_ids, + 'reason': reason + }) + all_sources.extend(step_sources) + + elif isinstance(step_data, str): + processed_steps.append({ + 'step': step_data, + 'sources': [], + 'source_ids': [], + 'reason': '' + }) + + if processed_steps: + action_list.append({ + 'plan_name': plan_name, + 'duration_weeks': duration_weeks, + 'actionSteps': processed_steps, + 'all_source_ids': list(all_source_ids), + 'all_sources': all_sources + }) + + print(f"\nParsed {len(action_list)} action plans from response") + return action_list + + +def post_process_actions_with_source(action_list, filtered_chunks, chunks_response): + try: + if not action_list: + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': [], + 'message': 'No actions to process' + } + + if not isinstance(action_list, list): + return { + 'status': 'error', + 'status_code': 400, + 'action_list': [], + 'message': 'Invalid action_list: must be a list' + } + + source_id_to_score = {chunk['source_id']: chunk['relevance_score'] for chunk in filtered_chunks} + + source_map = {} + if chunks_response and chunks_response.get("results"): + try: + for result in chunks_response["results"]: + if not isinstance(result, dict): + print(f"Skipping invalid result in post_process: {result}") + continue + + source_id = result.get('source_id', '') or result.get('metadata', {}).get('source_id', '') + + if not source_id: + print(f"Skipping result without source_id: {result}") + continue + + chunk_text = result.get('text', '') + metadata = result.get('metadata', {}) + description = metadata.get('summary', '') + title = metadata.get('title', '') or metadata.get('TITLE', '') + url = metadata.get('url', '') + organization_slug = metadata.get('company', '') + highlight_text = result.get('highlight_text', '') + + organization_dict = {} + if organization_slug: + try: + from chatbot.models import Company + company = Company.objects.filter(slug=organization_slug).first() + if company: + organization_dict = { + 'name': company.name, + 'slug': company.slug + } + else: + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + except Exception as org_error: + print(f"Error fetching company for slug '{organization_slug}': {str(org_error)}") + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + + chunk_data = { + 'highlight_text': highlight_text, + 'chunk': chunk_text + } + + if source_id not in source_map: + source_map[source_id] = { + 'source_id': source_id, + 'description': description, + 'title': title, + 'url': url, + 'organization': organization_dict, + 'chunks': [chunk_data] + } + else: + source_map[source_id]['chunks'].append(chunk_data) + + if not source_map[source_id]['description'] and description: + source_map[source_id]['description'] = description + if not source_map[source_id]['title'] and title: + source_map[source_id]['title'] = title + if not source_map[source_id]['url'] and url: + source_map[source_id]['url'] = url + if not source_map[source_id]['organization'] and organization_dict: + source_map[source_id]['organization'] = organization_dict + + except Exception as map_error: + print(f"Error creating source_map: {str(map_error)}") + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'message': f'Error mapping source data: {str(map_error)}' + } + + processed_actions = [] + for action_plan in action_list: + try: + if not isinstance(action_plan, dict): + print(f"Skipping invalid action plan: {action_plan}") + continue + + processed_steps = [] + for step_data in action_plan.get('actionSteps', []): + if isinstance(step_data, dict): + step_sources = [] + for source_id in step_data.get('source_ids', []): + score = source_id_to_score.get(source_id, 0) + source_info = source_map.get(source_id, { + 'source_id': source_id, + 'chunks': [], + 'description': '', + 'title': '', + 'url': '', + 'organization': {} + }) + + highlight_texts = [] + for src in step_data.get("sources", []): + if src.get("source_id") == source_id and src.get("highlight_text"): + highlight_texts.append(src.get("highlight_text")) + + chunks_with_highlights = [] + for i, chunk_data in enumerate(source_info.get('chunks', [])): + chunk_entry = { + 'chunk': chunk_data.get('chunk', ''), + 'highlight_text': chunk_data.get('highlight_text', '') + } + if i < len(highlight_texts): + chunk_entry['highlight_text'] = highlight_texts[i] + chunks_with_highlights.append(chunk_entry) + + step_sources.append({ + 'source_id': source_id, + 'score': score, + 'chunks': chunks_with_highlights, + 'description': source_info.get('description', ''), + 'title': source_info.get('title', ''), + 'url': source_info.get('url', ''), + 'organization': source_info.get('organization', {}), + 'chunk_count': len(chunks_with_highlights) + }) + + processed_steps.append({ + 'step': step_data.get('step', ''), + 'reason': step_data.get('reason', ''), + 'sources': step_sources + }) + elif isinstance(step_data, str): + processed_steps.append({ + 'step': step_data, + 'reason': '', + 'sources': [] + }) + + all_source_ids = action_plan.get('all_source_ids', []) + total_score = sum(source_id_to_score.get(sid, 0) for sid in all_source_ids) + avg_score = total_score / len(all_source_ids) if all_source_ids else 0 + + processed_action = { + 'plan_name': action_plan.get('plan_name', ''), + 'duration_weeks': action_plan.get('duration_weeks', 3), + 'actionSteps': processed_steps, + 'score': avg_score, + 'source_count': len(all_source_ids) + } + processed_actions.append(processed_action) + + except Exception as action_error: + print(f"Error processing action: {str(action_error)}") + continue + + return { + 'status': 'ok', + 'status_code': 200, + 'action_list': processed_actions, + 'message': f'Successfully processed {len(processed_actions)} actions with source information' + } + + except Exception as e: + print(f"Unexpected error in post_process_actions_with_source: {str(e)}") + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'action_list': [], + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/base_utils.py b/shikshalokam/utils/base_utils.py new file mode 100644 index 0000000..15bf8aa --- /dev/null +++ b/shikshalokam/utils/base_utils.py @@ -0,0 +1,222 @@ +import pandas as pd +from django.db import transaction, IntegrityError +from chatbot.models import Company, Profile, ProfileAddress +from shikshalokam.models import (Project, Task, Evidence, ProjectTemplate, ProjectStatus, Category, TaskMandatoryStatus) + +program_name_column_name = 'Program Name' +program_id_column_name = 'Program ID' +UUID_column_name = 'UUID' +user_type_column_name = 'User Type' +user_sub_type_column_name = 'User sub type' +declared_board_column_name = 'Declared Board' +org_associated_column_name = 'Org Name' +state_column_name = 'Declared State' +district_column_name = 'District' +block_column_name = 'Block' +category_name_column_name = "Category" +category_id_column_name = "Category ID" +template_title_column_name = "Solution" +template_id_column_name = "Solution ID" +template_description_column_name = "Solution Description" +project_id_column_name = 'Project ID' +title_column_name = 'Project Title' +objective_column_name = 'Project Objective' +duration_column_name = 'Project Duration' +status_column_name = 'Project Status' +start_date_column_name = 'Project start date of the user' +end_date_column_name = 'Project completion date of the user' +recommended_for_column_name = 'recommendedFor' +keywords_column_name = 'keywords' +project_resource_name_column_name = 'Project Learning Resource Name' +project_resource_link_column_name = 'Project Learning Resource Link' +project_evidence_column_name = 'Project Evidence' +project_remarks_column_name = 'Project Remarks' +task_name_column_name = 'Tasks' +task_id_column_name = 'Task ID' +task_mandatory_task_column_name = 'Task Status' +task_observation_name_column_name = 'Observation' +task_number_of_submission_observation_column_name = 'Number of submission' +task_evidence_column_name = 'Task Evidence' +task_remarks_column_name = 'Task Remarks' + + +def upload_csv_to_db(file_path='shikshalokam/utils/shikshalokamTest.csv', start_index=0, end_index=None): + df = pd.read_csv(file_path, on_bad_lines='skip') + + user_company = Company.objects.get(slug='shikshalokam') + grouped_rows = list(df.groupby(project_id_column_name)) + total_projects = len(grouped_rows) + + if end_index is None or end_index > total_projects: + end_index = total_projects + + for i in range(start_index, end_index): + project_id, group = grouped_rows[i] + try: + with transaction.atomic(): + for index, row in group.iterrows(): + first_name = row.get(UUID_column_name) + if not first_name or first_name == '': + print(f"Missing first_name for row: {row}") + raise ValueError("Missing first_name, rolling back transaction") + print('FIRST NAME: ', first_name) + + designation = row.get(user_type_column_name) + other_params = { + 'user_sub_type': row.get(user_sub_type_column_name), + 'declared_board': row.get(declared_board_column_name) + } + org_associated = row.get(org_associated_column_name) + + user_email = '{}@{}.com'.format(first_name, 'shikshalokam') + if not user_company or not user_email: + print(f"Invalid company or email for first_name: {first_name}") + continue + print(f"Fetching/Creating Profile for email: {user_email}") + + author, author_created = Profile.objects.get_or_create( + email=user_email, company=user_company, + defaults={ + 'first_name': first_name, + 'designation': designation, + 'other_params': other_params, + 'org_associated': org_associated, + } + ) + print(f"Profile created: {author_created}, Profile ID: {author.id if author else 'None'}") + if author_created: + ProfileAddress.objects.create( + profile=author, + state=row.get(state_column_name), + district=row.get(district_column_name), + city=row.get(block_column_name), + ) + + category_name = row.get(category_name_column_name) + category_id = row.get(category_id_column_name) + category, _ = Category.objects.get_or_create( + name=category_name, + category_id=category_id + ) + + template_title = row.get(template_title_column_name) + template_id = row.get(template_id_column_name) + description = row.get(template_description_column_name) + project_template, _ = ProjectTemplate.objects.get_or_create( + category=category, + title=template_title, + template_id=template_id, + description=description + ) + + project_title = row.get(title_column_name) + project_objective = row.get(objective_column_name) + project_duration = row.get(duration_column_name) + project_status = row.get(status_column_name) + if project_status: + project_status = project_status.strip().lower() + for status_choice in ProjectStatus.choices: + if project_status == status_choice[0].lower(): + project_status = status_choice[0] + break + project_start_date = row.get(start_date_column_name) + project_end_date = row.get(end_date_column_name) + project_recommended_for = row.get(recommended_for_column_name) + project_keywords = row.get(keywords_column_name) + project_resource_name = row.get(project_resource_name_column_name) + project_resource_link = row.get(project_resource_link_column_name) + print(f"Creating/Updating Project for author: {author.id}, email: {author.email}") + print(f"Debug - Project ID: {project_id}, Project Row: {row}") + + project, created = Project.objects.get_or_create( + project_id=project_id, + defaults={ + 'project_template': project_template, + 'author': author, + 'title': project_title, + 'objective': project_objective, + 'duration': project_duration, + 'project_status': project_status, + 'project_start_date': project_start_date, + 'project_end_date': project_end_date, + 'recommended_for': project_recommended_for, + 'keywords': project_keywords, + 'resource_name': project_resource_name, + 'resource_link': project_resource_link + } + ) + print(f"Project created: {created}, Project ID: {project.id if project else 'None'}") + + if not created: + print(f"Project already exists. Updating Project ID: {project_id}") + project.project_template = project_template + project.author = author + project.title = project_title + project.objective = project_objective + project.duration = project_duration + project.project_status = project_status + project.project_start_date = project_start_date + project.project_end_date = project_end_date + project.recommended_for = project_recommended_for + project.keywords = project_keywords + project.resource_name = project_resource_name + project.resource_link = project_resource_link + project.save() + + import_tasks_and_related_data(row, project) + + print(f"Successfully processed rows for project ID: {project_id}") + + except IntegrityError as e: + print(f"IntegrityError in rows for project ID: {project_id} - {str(e)}") + except Exception as e: + print(f"Error in rows for project ID: {project_id} - {str(e)}") + + +def import_tasks_and_related_data(row, project): + task_name = row.get(task_name_column_name) + parent_task_id = row.get(task_id_column_name) + task_id = row.get(task_id_column_name) + mandatory_task = row.get(task_mandatory_task_column_name) + observation_name = row.get(task_observation_name_column_name) + number_of_submission_observation = row.get(task_number_of_submission_observation_column_name) + if mandatory_task: + mandatory_task = mandatory_task.strip().lower() + for task_choice in TaskMandatoryStatus.choices: + if mandatory_task == task_choice[0].lower(): + mandatory_task = task_choice[0] + break + task, _ = Task.objects.get_or_create( + project=project, + defaults={ + 'task_name': task_name, + 'parent_task_id': parent_task_id, + 'task_id': task_id, + 'mandatory_task': mandatory_task, + 'observation_name': observation_name, + 'number_of_submission_observation': number_of_submission_observation + } + ) + import_evidence(row, task, project) + + +def import_evidence(row, task, project): + evidence_link_task = row.get(task_evidence_column_name) + remark_task = row.get(task_remarks_column_name) + + evidence_link_project = row.get(project_evidence_column_name) + remark_project = row.get(project_remarks_column_name) + + if evidence_link_project: + Evidence.objects.get_or_create( + project=project, + evidence_link=evidence_link_project, + remark=remark_project + ) + + if evidence_link_task: + Evidence.objects.get_or_create( + task=task, + evidence_link=evidence_link_task, + remark=remark_task + ) diff --git a/shikshalokam/utils/chunks_utils.py b/shikshalokam/utils/chunks_utils.py new file mode 100644 index 0000000..30b4f10 --- /dev/null +++ b/shikshalokam/utils/chunks_utils.py @@ -0,0 +1,89 @@ +def normalize_source_id(source_id): + """ + Normalize source ID for consistent comparison. + Converts to integer if possible, otherwise keeps as string. + """ + if source_id is None: + return None + + try: + return int(source_id) + except (ValueError, TypeError): + return str(source_id).strip() + + +def filter_and_sort_chunks(chunks_response, filter_score, top_k=None): + filtered_chunks = [] + + if not chunks_response or not chunks_response.get("results"): + return filtered_chunks + + for result in chunks_response.get("results", []): + if not isinstance(result, dict): + continue + + relevance_score = result.get('score', 0) + + if relevance_score >= filter_score: + chunk_text = result.get('text', '') + + if chunk_text and len(chunk_text.strip()) > 20: + source_id = result.get('source_id', '') or result.get('metadata', {}).get('source_id', '') + + normalized_id = normalize_source_id(source_id) + + filtered_chunks.append({ + 'chunk_text': chunk_text.strip(), + 'source_id': normalized_id, + 'original_source_id': source_id, + 'relevance_score': relevance_score, + 'full_result': result + }) + + filtered_chunks.sort(key=lambda x: x['relevance_score'], reverse=True) + + if top_k and len(filtered_chunks) > top_k: + filtered_chunks = filtered_chunks[:top_k] + + return filtered_chunks + + +def prepare_chunks_for_template(filtered_chunks): + chunks_data = [] + for idx, chunk in enumerate(filtered_chunks, 1): + chunks_data.append({ + 'index': idx, + 'text': chunk['chunk_text'], + 'source_id': chunk['source_id'], + 'score': chunk['relevance_score'] + }) + return chunks_data + + +def render_template_with_context(tag_context, context_data, fallback_template=None): + from jinja2 import Template + + if tag_context: + template = Template(tag_context) + return template.render(context_data) + elif fallback_template: + template = Template(fallback_template) + return template.render(context_data) + else: + return str(context_data) + + +def validate_inputs(user_input, company_bot, required_attrs=None): + if not user_input or not isinstance(user_input, str): + return {'valid': False, 'message': 'Invalid input: must be a non-empty string'} + + if not company_bot: + return {'valid': False, 'message': 'Invalid company_bot: company_bot object is required'} + + if required_attrs: + missing_attrs = [attr for attr in required_attrs if not hasattr(company_bot, attr)] + if missing_attrs: + return {'valid': False, + 'message': f'Invalid company_bot: missing required attributes ({", ".join(missing_attrs)})'} + + return {'valid': True, 'message': 'Valid inputs'} diff --git a/shikshalokam/utils/mitra_base_utils.py b/shikshalokam/utils/mitra_base_utils.py new file mode 100644 index 0000000..e134f1c --- /dev/null +++ b/shikshalokam/utils/mitra_base_utils.py @@ -0,0 +1,104 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model +from chatbot.models import CompanyBot +from chatbot.utils.shikshalokam_mitra_utils import create_mitra_project_utils +from shikshalokam.utils.action_list.action_parser import unwrap_tool_values +from shikshalokam.utils.action_list.action_validator import validate_and_fix_action_list +import json +import logging + +logger = logging.getLogger('django') + +def get_mitra_paraphrase_utils(messages, company_bot, session_id): + paraphrase_prompt = company_bot.context + paraphrase_prompt = [{'text': paraphrase_prompt}] + + paraphrase_response = handle_bedrock_model( + system_prompt=paraphrase_prompt, messages=messages, model_name = company_bot.llm_model, + temperature = company_bot.bot_temperature, max_token = company_bot.max_token, company_bot=company_bot, tools=json.loads(company_bot.tool_context) + ) + + + logger.info("Paraphrased response: %s", json.dumps(paraphrase_response)) + + validated_response = None + try: + validate_bot = CompanyBot.objects.filter(route='/paraphrase_bot').first() + if validate_bot: + validated_response = validate_and_fix_action_list( + messages=messages, response_json=paraphrase_response, company_bot=validate_bot + ) + + logger.info("Validation response: %s", json.dumps(validated_response)) + logger.info("Validation applied using validate_bot for paraphrase") + else: + logger.info("No validate_bot found with route='/paraphrase_bot', skipping validation") + + except CompanyBot.DoesNotExist: + logger.error("validate_bot not found, proceeding without validation") + except Exception as validation_error: + logger.error(f"Validation failed: {validation_error}, proceeding with original response") + + final_response = paraphrase_response + create_mitra_project_utils(session=session_id, description=json.dumps(final_response)) + + if validated_response: + final_response = validated_response + + if 'output' in final_response: + content = ( + final_response + .get('output', {}) + .get('message', {}) + .get('content', []) + ) + + if isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input') + if tool_input: + final_response = tool_input + break + + extracted_data = ( + final_response.pop("parameters", None) + or final_response.pop("input", None) + ) + + if extracted_data: + extracted_data = unwrap_tool_values(extracted_data) + final_response = extracted_data + + logger.info( + "Extracted paraphrase data: %s", + json.dumps(final_response, default=str) + ) + + if isinstance(final_response, str): + try: + final_response = json.loads(final_response) + except Exception: + import json_repair + final_response = json_repair.repair_json( + final_response, return_objects=True + ) + + return final_response + + +def generate_title_utils(input_data, company_bot): + prompt = company_bot.context + messages = [{ + 'role': 'user', + 'content': [{'text': f"{input_data}"}] + }] + + prompt = [{'text': prompt}] + + response = handle_bedrock_model( + system_prompt=prompt, messages=messages, model_name = company_bot.llm_model, + temperature = company_bot.bot_temperature, max_token = company_bot.max_token, + company_bot=company_bot + ) + response = response.get('title') + return response diff --git a/shikshalokam/utils/objective_list/objective_parser.py b/shikshalokam/utils/objective_list/objective_parser.py new file mode 100644 index 0000000..eb820e8 --- /dev/null +++ b/shikshalokam/utils/objective_list/objective_parser.py @@ -0,0 +1,180 @@ +from shikshalokam.utils.action_list.action_parser import unwrap_tool_values +from shikshalokam.utils.chunks_utils import normalize_source_id +import json_repair +import json +import logging + +logger = logging.getLogger('django') + + +def normalize_objectives(objectives): + """ + Normalizes objectives data. + """ + if not objectives: + raise ValueError("EMPTY_OBJECTIVES") + + if isinstance(objectives, str): + try: + objectives = json.loads(objectives) + except Exception: + try: + objectives = json_repair.repair_json( + objectives, return_objects=True + ) + except Exception: + raise ValueError("OBJECTIVES_MALFORMED_JSON") + + return objectives + + +def validate_objectives(objectives): + """ + Validates normalized objectives. + """ + if not isinstance(objectives, list): + raise ValueError("OBJECTIVES_NOT_LIST") + + if not objectives: + raise ValueError("EMPTY_OBJECTIVES") + + for i, obj in enumerate(objectives): + if not isinstance(obj, dict): + raise ValueError(f"INVALID_OBJECTIVE_OBJECT_{i}") + + objective_text = obj.get("objective", obj.get("text", "")).strip() + + if not isinstance(objective_text, str) or not objective_text: + raise ValueError(f"EMPTY_OBJECTIVE_TEXT_{i}") + if objective_text.lower() in ("type", "value"): + raise ValueError(f"INVALID_OBJECTIVE_TEXT_{i}") + + +def parse_llm_objective_response(response, filtered_chunks): + """ + Parse LLM response into structured objective list with source validation. + """ + try: + print("llm response: ", response) + if not response or not isinstance(response, dict): + raise ValueError("INVALID_LLM_RESPONSE") + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + response = tool_input + break + + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data: + extracted_data = unwrap_tool_values(extracted_data) + response = extracted_data + + print("\nextracted_data: ", extracted_data) + logger.info(f"extracted_data: {extracted_data}") + + objectives_from_response = ( + response.get('objectives') or + response.get('objective_list') or + response.get('objective') or + [] + ) + + if isinstance(objectives_from_response, dict): + if 'value' in objectives_from_response: + objectives_from_response = objectives_from_response['value'] + elif 'items' in objectives_from_response: + objectives_from_response = objectives_from_response['items'] + + print("objectives_from_response: ", objectives_from_response) + logger.info(f"objectives_from_response: {objectives_from_response}") + + if not isinstance(objectives_from_response, list): + objectives_from_response = [objectives_from_response] if objectives_from_response else [] + + # Normalize and validate + objectives_from_response = normalize_objectives(objectives_from_response) + validate_objectives(objectives_from_response) + + objective_list = [] + + # Create set of normalized source IDs for validation + valid_source_ids = set() + for chunk in filtered_chunks: + normalized_id = normalize_source_id(chunk.get('source_id')) + if normalized_id: + valid_source_ids.add(normalized_id) + + print(f"Valid source IDs (normalized): {valid_source_ids}") + + for obj in objectives_from_response: + if isinstance(obj, dict): + objective_text = obj.get('objective', obj.get('text', '')) + objective_text = objective_text.strip() + sources = obj.get('sources', []) + reason = obj.get('reason', '') + + if isinstance(sources, str): + if sources.strip() in ("[]", ""): + sources = [] + else: + try: + sources = json.loads(sources) + except: + sources = [] + + if sources is None: + sources = [] + + if not isinstance(sources, list): + sources = [sources] + + filtered_sources = [] + validated_source_ids = [] + + for src in sources: + if isinstance(src, dict): + raw_source_id = src.get("source_id") + normalized_id = normalize_source_id(raw_source_id) + highlight_text = src.get("highlight_text", "") + + if normalized_id and normalized_id in valid_source_ids: + # Find original ID from chunks + original_id = None + for chunk in filtered_chunks: + if normalize_source_id(chunk.get('source_id')) == normalized_id: + original_id = chunk.get('source_id') + break + + if original_id is not None: + filtered_sources.append({ + "source_id": original_id, + "highlight_text": highlight_text + }) + validated_source_ids.append(original_id) + else: + print( + f"Warning: source_id '{raw_source_id}' (normalized: '{normalized_id}') not found in valid chunks") + + has_sources = bool(sources) + has_valid_sources = bool(validated_source_ids) + + if objective_text and (has_valid_sources or not has_sources): + objective_list.append({ + 'objective': objective_text.strip(), + 'sources': filtered_sources, + 'source_ids': validated_source_ids, + 'reason': reason, + 'is_evidence_optional': not has_sources + }) + + print(f"\nParsed {len(objective_list)} objectives from response") + return objective_list + + except ValueError as e: + logger.error(f"Objectives validation failed: {str(e)}") + raise diff --git a/shikshalokam/utils/objective_list/objective_processor.py b/shikshalokam/utils/objective_list/objective_processor.py new file mode 100644 index 0000000..15c927c --- /dev/null +++ b/shikshalokam/utils/objective_list/objective_processor.py @@ -0,0 +1,216 @@ +from shikshalokam.utils.chunks_utils import normalize_source_id + + +def post_process_objectives_with_source(objective_list, filtered_chunks, chunks_response): + """ + Enrich objective list with complete source information including chunks, scores, and metadata. + """ + try: + if not objective_list: + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': [], + 'message': 'No objectives to process' + } + + if not isinstance(objective_list, list): + return { + 'status': 'error', + 'status_code': 400, + 'objective_list': [], + 'message': 'Invalid objective_list: must be a list' + } + + source_id_to_score = {} + for chunk in filtered_chunks: + source_id = chunk.get('source_id') + if source_id is not None: + source_id_to_score[source_id] = chunk['relevance_score'] + normalized = normalize_source_id(source_id) + if normalized: + source_id_to_score[normalized] = chunk['relevance_score'] + + source_map = {} + if chunks_response and chunks_response.get("results"): + try: + for result in chunks_response["results"]: + if not isinstance(result, dict): + print(f"Skipping invalid result in post_process: {result}") + continue + + source_id = result.get('source_id', '') or result.get('metadata', {}).get('source_id', '') + + if not source_id: + print(f"Skipping result without source_id: {result}") + continue + + chunk_text = result.get('text', '') + metadata = result.get('metadata', {}) + description = metadata.get('summary', '') + title = metadata.get('title', '') or metadata.get('TITLE', '') + url = metadata.get('url', '') + organization_slug = metadata.get('company', '') + highlight_text = result.get('highlight_text', '') + + organization_dict = {} + if organization_slug: + try: + from chatbot.models import Company + company = Company.objects.filter(slug=organization_slug).first() + if company: + organization_dict = { + 'name': company.name, + 'slug': company.slug + } + else: + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + except Exception as org_error: + print(f"Error fetching company for slug '{organization_slug}': {str(org_error)}") + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + + chunk_data = { + 'highlight_text': highlight_text, + 'chunk': chunk_text + } + + if source_id not in source_map: + source_entry = { + 'source_id': source_id, + 'description': description, + 'title': title, + 'url': url, + 'organization': organization_dict, + 'chunks': [chunk_data] + } + source_map[source_id] = source_entry + + normalized_id = normalize_source_id(source_id) + if normalized_id and normalized_id != source_id: + source_map[normalized_id] = source_entry + else: + source_map[source_id]['chunks'].append(chunk_data) + + if not source_map[source_id]['description'] and description: + source_map[source_id]['description'] = description + if not source_map[source_id]['title'] and title: + source_map[source_id]['title'] = title + if not source_map[source_id]['url'] and url: + source_map[source_id]['url'] = url + if not source_map[source_id]['organization'] and organization_dict: + source_map[source_id]['organization'] = organization_dict + + except Exception as map_error: + print(f"Error creating source_map: {str(map_error)}") + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'message': f'Error mapping source data: {str(map_error)}' + } + + processed_objectives = [] + for objective in objective_list: + try: + if not isinstance(objective, dict): + print(f"Skipping invalid objective: {objective}") + continue + + source_ids = objective.get('source_ids', []) + if not isinstance(source_ids, list): + source_ids = [source_ids] if source_ids else [] + + sources = [] + total_score = 0 + for source_id in source_ids: + if isinstance(source_id, list): + source_id = source_id[0] if source_id else '' + + score = source_id_to_score.get(source_id, 0) + if score == 0: + normalized_id = normalize_source_id(source_id) + score = source_id_to_score.get(normalized_id, 0) + + total_score += score + + source_info = source_map.get(source_id) + if not source_info: + normalized_id = normalize_source_id(source_id) + source_info = source_map.get(normalized_id) + + if not source_info: + source_info = { + 'source_id': source_id, + 'chunks': [], + 'description': '', + 'title': '', + 'url': '', + 'organization': {} + } + + highlight_texts = [] + for src in objective.get("sources", []): + src_id_normalized = normalize_source_id(src.get("source_id")) + source_id_normalized = normalize_source_id(source_id) + if src_id_normalized == source_id_normalized and src.get("highlight_text"): + highlight_texts.append(src.get("highlight_text")) + + chunks_with_highlights = [] + for i, chunk_data in enumerate(source_info.get('chunks', [])): + chunk_entry = { + 'chunk': chunk_data.get('chunk', ''), + 'highlight_text': chunk_data.get('highlight_text', '') + } + if i < len(highlight_texts): + chunk_entry['highlight_text'] = highlight_texts[i] + chunks_with_highlights.append(chunk_entry) + + sources.append({ + 'source_id': source_id, + 'score': score, + 'chunks': chunks_with_highlights, + 'description': source_info.get('description', ''), + 'title': source_info.get('title', ''), + 'url': source_info.get('url', ''), + 'organization': source_info.get('organization', {}), + 'chunk_count': len(chunks_with_highlights) + }) + + avg_score = total_score / len(source_ids) if source_ids else 0 + + processed_objective = { + 'text': objective.get('objective', objective.get('text', '')), + 'reason': objective.get('reason', ''), + 'score': avg_score, + 'sources': sources, + 'source_count': len(sources) + } + processed_objectives.append(processed_objective) + + except Exception as obj_error: + print(f"Error processing objective: {str(obj_error)}") + continue + + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': processed_objectives, + 'message': f'Successfully processed {len(processed_objectives)} objectives with source information' + } + + except Exception as e: + print(f"Unexpected error in post_process_objectives_with_source: {str(e)}") + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/objective_list/objective_utils.py b/shikshalokam/utils/objective_list/objective_utils.py new file mode 100644 index 0000000..cab4b27 --- /dev/null +++ b/shikshalokam/utils/objective_list/objective_utils.py @@ -0,0 +1,197 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model, handle_openai_model +from chatbot.models import CompanyBot, LLMProvider +from shikshalokam.utils.action_list.action_validator import validate_and_fix_action_list +from shikshalokam.utils.chunks_utils import validate_inputs, filter_and_sort_chunks, prepare_chunks_for_template, \ + render_template_with_context +import logging +from shikshalokam.utils.objective_list.objective_parser import parse_llm_objective_response +import json_repair + +logger = logging.getLogger('django') + + +def generate_objective_utils(user_problem_statement, company_bot): + try: + from chatbot.utils.chat_query_handler import query_text_search + + required_attrs = ['top_k', 'filter_score', 'context', 'tag_context', 'llm_model', 'bot_temperature', + 'max_token'] + validation = validate_inputs(user_problem_statement, company_bot, required_attrs) + + if not validation['valid']: + return { + 'status': 'error', + 'status_code': 400, + 'objective_list': [], + 'chunks_response': None, + 'message': validation['message'] + } + + try: + chunks_response = query_text_search( + query=user_problem_statement, + priority="P1", + limit=company_bot.top_k + ) + + if chunks_response.get('error'): + print(f"Error while fetching chunks: {chunks_response.get('error')}") + logger.info(f"Error while fetching chunks: {chunks_response.get('error')}") + + except Exception as db_error: + print(f"Error while fetching chunks: {db_error}") + logger.info(f"Error while fetching chunks: {db_error}") + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': None, + 'message': f'Database query failed: {str(db_error)}' + } + + filtered_chunks = [] + if chunks_response and chunks_response.get("results"): + filtered_chunks = filter_and_sort_chunks( + chunks_response, company_bot.filter_score, company_bot.top_k + ) + + logger.info(f"filtered_chunks: {filtered_chunks}") + + try: + chunks_data = prepare_chunks_for_template(filtered_chunks) + + context_data = { + 'user_problem_statement': user_problem_statement, + 'chunks': chunks_data, + 'total_chunks': len(chunks_data) + } + + rendered_content = render_template_with_context( + company_bot.tag_context, context_data + ) + + if company_bot and company_bot.provider == LLMProvider.OPENAI: + messages = [ + { + 'role': 'user', + 'content': rendered_content + } + ] + + context = company_bot.context + context += f"\n{company_bot.end_context}" if company_bot.end_context else "" + system_prompt = [{"role": "system", "content": context}] + + tools = None + tool_choice = None + try: + tool_context = json_repair.repair_json(company_bot.tool_context, return_objects=True) + if tool_context: + tools = tool_context.get("tool") + tool_choice = tool_context.get("tool_choice", "auto") + + logger.info("Using state machine tool_context") + except Exception as e: + logger.error(f"Failed to parse state machine tool_context: {e}") + + logger.info("-----------------OPENAI OBJECTIVES---------------------------------", ) + logger.info(f"openai system_prompt: {system_prompt}") + logger.info(f"openai messages: {messages}") + response = handle_openai_model( + messages=messages, system_prompt=system_prompt, max_token=company_bot.max_token, + temperature=company_bot.bot_temperature, company_bot=company_bot, + top_p=company_bot.filter_score if company_bot.filter_score else None, + tool_choice=tool_choice, tools=tools, stream=False, is_json_response=True + ) + else: + messages = [{ + 'role': 'user', + 'content': [{'text': rendered_content}] + }] + + system_prompt = [{'text': company_bot.context}] + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + try: + validate_bot = CompanyBot.objects.filter(route='/validate_objective_list').first() + if validate_bot: + response = validate_and_fix_action_list( + messages=messages, response_json=response, company_bot=validate_bot + ) + logger.info("Validation applied using validate_bot") + else: + logger.info("No validate_bot found with route='/validate_objective_list', skipping validation") + + except CompanyBot.DoesNotExist: + logger.error("validate_bot not found, proceeding without validation") + except Exception as validation_error: + logger.error(f"Validation failed: {validation_error}, proceeding with original response") + + if not response: + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': chunks_response, + 'message': 'Invalid response from LLM' + } + + objective_list = parse_llm_objective_response(response, filtered_chunks) + logger.info(f"objective_list: {objective_list}") + + if not objective_list: + raise ValueError("LLM returned empty objectives list") + + except ValueError as e: + return { + 'status': 'error', + 'status_code': 422, + 'objective_list': [], + 'chunks_response': chunks_response, + 'message': str(e) + } + + except Exception as llm_error: + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': chunks_response, + 'message': f'Error generating objectives: {str(llm_error)}' + } + + total_results = chunks_response.get('total_results', 0) + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': objective_list, + 'filtered_chunks': filtered_chunks, + 'total_objectives': len(objective_list), + 'total_chunks_used': len(filtered_chunks), + 'total_chunks_found': total_results, + 'total_results': total_results, + 'chunks_response': chunks_response, + 'message': f'Successfully generated {len(objective_list)} objectives from {len(filtered_chunks)} chunks' + } + + except Exception as e: + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'total_objectives': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': None, + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/objective_utils.py b/shikshalokam/utils/objective_utils.py new file mode 100644 index 0000000..9b26ecc --- /dev/null +++ b/shikshalokam/utils/objective_utils.py @@ -0,0 +1,428 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model +from shikshalokam.utils.chunks_utils import validate_inputs, filter_and_sort_chunks, prepare_chunks_for_template, \ + render_template_with_context +import json_repair +import json +import logging + +logger = logging.getLogger('django') + + +def generate_objective_utils(user_problem_statement, company_bot): + try: + from chatbot.utils.chat_query_handler import query_text_search + + required_attrs = ['top_k', 'filter_score', 'context', 'tag_context', 'llm_model', 'bot_temperature', + 'max_token'] + validation = validate_inputs(user_problem_statement, company_bot, required_attrs) + + if not validation['valid']: + return { + 'status': 'error', + 'status_code': 400, + 'objective_list': [], + 'chunks_response': None, + 'message': validation['message'] + } + + try: + chunks_response = query_text_search( + query=user_problem_statement, + priority="P1", + limit=company_bot.top_k + ) + + if chunks_response.get('error'): + return { + 'status': 'error', + 'status_code': chunks_response.get('status_code', 500), + 'objective_list': [], + 'chunks_response': None, + 'message': chunks_response.get('message', 'API request failed') + } + + except Exception as db_error: + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': None, + 'message': f'Database query failed: {str(db_error)}' + } + + if not chunks_response or not chunks_response.get("results"): + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': [], + 'total_objectives': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': chunks_response, + 'message': 'No chunks found from text-search API' + } + + filtered_chunks = filter_and_sort_chunks( + chunks_response, company_bot.filter_score, company_bot.top_k + ) + + logger.info(f"filtered_chunks: {filtered_chunks}") + + if not filtered_chunks: + total_chunks = len(chunks_response.get("results", [])) + max_score = max([r.get('score', 0) for r in chunks_response.get("results", [])], default=0) + + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': [], + 'total_objectives': 0, + 'total_chunks_used': 0, + 'total_chunks_found': total_chunks, + 'total_results': total_chunks, + 'chunks_response': chunks_response, + 'message': f'No chunks met filter criteria. Found {total_chunks} chunks, max score: {max_score:.4f}, threshold: {company_bot.filter_score}' + } + + try: + chunks_data = prepare_chunks_for_template(filtered_chunks) + + context_data = { + 'user_problem_statement': user_problem_statement, + 'chunks': chunks_data, + 'total_chunks': len(chunks_data) + } + + rendered_content = render_template_with_context( + company_bot.tag_context, context_data + ) + + messages = [{ + 'role': 'user', + 'content': [{'text': rendered_content}] + }] + + system_prompt = [{'text': company_bot.context}] + import json_repair + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + + response = handle_bedrock_model( + system_prompt=system_prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + if not response: + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': chunks_response, + 'message': 'Invalid response from LLM' + } + + objective_list = parse_llm_objective_response(response, filtered_chunks) + + except Exception as llm_error: + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'chunks_response': chunks_response, + 'message': f'Error generating objectives: {str(llm_error)}' + } + + total_results = chunks_response.get('total_results', 0) + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': objective_list, + 'filtered_chunks': filtered_chunks, + 'total_objectives': len(objective_list), + 'total_chunks_used': len(filtered_chunks), + 'total_chunks_found': total_results, + 'total_results': total_results, + 'chunks_response': chunks_response, + 'message': f'Successfully generated {len(objective_list)} objectives from {len(filtered_chunks)} chunks' + } + + except Exception as e: + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'total_objectives': 0, + 'total_chunks_used': 0, + 'total_chunks_found': 0, + 'total_results': 0, + 'chunks_response': None, + 'message': f'Internal server error: {str(e)}' + } + + +def parse_llm_objective_response(response, filtered_chunks): + print("llm response: ", response) + if not response or not isinstance(response, dict): + return [] + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + response = tool_input + break + + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + response = extracted_data + + print("\n extracted_data: ", extracted_data) + objectives_from_response = ( + response.get('objectives') or + response.get('objective_list') or + response.get('objective') or + [] + ) + + if isinstance(objectives_from_response, dict): + if 'value' in objectives_from_response: + objectives_from_response = objectives_from_response['value'] + elif 'items' in objectives_from_response: + objectives_from_response = objectives_from_response['items'] + + print("objectives_from_response: ", objectives_from_response) + + if isinstance(objectives_from_response, str): + try: + objectives_from_response = json_repair.repair_json(objectives_from_response, return_objects=True) + except: + try: + objectives_from_response = json.loads(objectives_from_response) + except: + objectives_from_response = [] + + if not isinstance(objectives_from_response, list): + objectives_from_response = [objectives_from_response] if objectives_from_response else [] + + objective_list = [] + valid_source_ids = {chunk['source_id'] for chunk in filtered_chunks} + + for obj in objectives_from_response: + if isinstance(obj, dict): + objective_text = obj.get('objective', obj.get('text', '')) + sources = obj.get('sources', []) + source_ids = [src.get("source_id") for src in sources if src.get("source_id")] + reason = obj.get('reason', '') + + if not isinstance(source_ids, list): + source_ids = [source_ids] if source_ids else [] + + validated_source_ids = [sid for sid in source_ids if sid in valid_source_ids] + + if objective_text and validated_source_ids: + filtered_sources = [ + src for src in sources + if src.get("source_id") in validated_source_ids + ] + + objective_list.append({ + 'objective': objective_text.strip(), + 'sources': filtered_sources, + 'source_ids': validated_source_ids, + 'reason': reason + }) + + return objective_list + + +def post_process_objectives_with_source(objective_list, filtered_chunks, chunks_response): + try: + if not objective_list: + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': [], + 'message': 'No objectives to process' + } + + if not isinstance(objective_list, list): + return { + 'status': 'error', + 'status_code': 400, + 'objective_list': [], + 'message': 'Invalid objective_list: must be a list' + } + + source_id_to_score = {chunk['source_id']: chunk['relevance_score'] for chunk in filtered_chunks} + + source_map = {} + if chunks_response and chunks_response.get("results"): + try: + for result in chunks_response["results"]: + if not isinstance(result, dict): + print(f"Skipping invalid result in post_process: {result}") + continue + + source_id = result.get('source_id', '') or result.get('metadata', {}).get('source_id', '') + + if not source_id: + print(f"Skipping result without source_id: {result}") + continue + + chunk_text = result.get('text', '') + metadata = result.get('metadata', {}) + description = metadata.get('summary', '') + title = metadata.get('title', '') or metadata.get('TITLE', '') + url = metadata.get('url', '') + organization_slug = metadata.get('company', '') + highlight_text = result.get('highlight_text', '') + + organization_dict = {} + if organization_slug: + try: + from chatbot.models import Company + company = Company.objects.filter(slug=organization_slug).first() + if company: + organization_dict = { + 'name': company.name, + 'slug': company.slug + } + else: + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + except Exception as org_error: + print(f"Error fetching company for slug '{organization_slug}': {str(org_error)}") + organization_dict = { + 'name': organization_slug, + 'slug': organization_slug + } + + chunk_data = { + 'highlight_text': highlight_text, + 'chunk': chunk_text + } + + if source_id not in source_map: + source_map[source_id] = { + 'source_id': source_id, + 'description': description, + 'title': title, + 'url': url, + 'organization': organization_dict, + 'chunks': [chunk_data] + } + else: + source_map[source_id]['chunks'].append(chunk_data) + + if not source_map[source_id]['description'] and description: + source_map[source_id]['description'] = description + if not source_map[source_id]['title'] and title: + source_map[source_id]['title'] = title + if not source_map[source_id]['url'] and url: + source_map[source_id]['url'] = url + if not source_map[source_id]['organization'] and organization_dict: + source_map[source_id]['organization'] = organization_dict + + except Exception as map_error: + print(f"Error creating source_map: {str(map_error)}") + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'message': f'Error mapping source data: {str(map_error)}' + } + + processed_objectives = [] + for objective in objective_list: + try: + if not isinstance(objective, dict): + print(f"Skipping invalid objective: {objective}") + continue + + source_ids = objective.get('source_ids', []) + if not isinstance(source_ids, list): + source_ids = [source_ids] if source_ids else [] + + sources = [] + total_score = 0 + for source_id in source_ids: + if isinstance(source_id, list): + source_id = source_id[0] if source_id else '' + score = source_id_to_score.get(source_id, 0) + total_score += score + source_info = source_map.get(source_id, { + 'source_id': source_id, + 'chunks': [], + 'description': '', + 'title': '', + 'url': '', + 'organization': {} + }) + + highlight_texts = [] + for src in objective.get("sources", []): + if src.get("source_id") == source_id and src.get("highlight_text"): + highlight_texts.append(src.get("highlight_text")) + + chunks_with_highlights = [] + for i, chunk_data in enumerate(source_info.get('chunks', [])): + chunk_entry = { + 'chunk': chunk_data.get('chunk', ''), + 'highlight_text': chunk_data.get('highlight_text', '') + } + if i < len(highlight_texts): + chunk_entry['highlight_text'] = highlight_texts[i] + chunks_with_highlights.append(chunk_entry) + + sources.append({ + 'source_id': source_id, + 'score': score, + 'chunks': chunks_with_highlights, + 'description': source_info.get('description', ''), + 'title': source_info.get('title', ''), + 'url': source_info.get('url', ''), + 'organization': source_info.get('organization', {}), + 'chunk_count': len(chunks_with_highlights) + }) + + avg_score = total_score / len(source_ids) if source_ids else 0 + + processed_objective = { + 'text': objective.get('objective', objective.get('text', '')), + 'reason': objective.get('reason', ''), + 'score': avg_score, + 'sources': sources, + 'source_count': len(sources) + } + processed_objectives.append(processed_objective) + + except Exception as obj_error: + print(f"Error processing objective: {str(obj_error)}") + continue + + return { + 'status': 'ok', + 'status_code': 200, + 'objective_list': processed_objectives, + 'message': f'Successfully processed {len(processed_objectives)} objectives with source information' + } + + except Exception as e: + print(f"Unexpected error in post_process_objectives_with_source: {str(e)}") + import traceback + traceback.print_exc() + return { + 'status': 'error', + 'status_code': 500, + 'objective_list': [], + 'message': f'Internal server error: {str(e)}' + } diff --git a/shikshalokam/utils/project_utils.py b/shikshalokam/utils/project_utils.py new file mode 100644 index 0000000..1c907b0 --- /dev/null +++ b/shikshalokam/utils/project_utils.py @@ -0,0 +1,114 @@ +import json +import traceback +import os +import requests + +from chatbot.models import SessionFlowName, CompanyChat, Profile, StoryMedia, MediaTypeChoices +from chatbot.utils.shikshalokam_mitra_utils import get_stored_conversation, get_stored_chathistory +from shikshalokam.models import Task, Project + + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def update_project_status_utils(project_id, access_token, status): + try: + url = f"https://{base_url}/userProjects/update/{project_id}" + print("using url: ", url) + if access_token.startswith('"') and access_token.endswith('"'): + access_token = access_token[1:-1] + headers = { + "X-auth-token": access_token, + } + + request_body = { + "reflectionStatus": status + } + print("request_body: ", request_body) + print("request_body: ", request_body) + print("type: ", type(request_body)) + print("type: ", type(request_body.get("story"))) + response = requests.post(url, headers=headers, json=request_body) + print("Response: ", response) + print("response: ", response.json()) + + return response.json() + + except Exception as e: + traceback.print_exc() + print(f"Failed to update project status: {str(e)}") + + +def get_project_formatted_data(user_project): + tasks = Task.objects.filter(project=user_project) + task_names = [task.task_name for task in tasks] + task_names_str = ', '.join(task_names) + + project_data = { + 'problem_statement': user_project.expected_problem_statement, + 'objective': user_project.expected_objective, + 'action_steps': task_names_str, + 'duration': user_project.expected_duration.strip() + " week" + if "week" not in user_project.expected_duration.lower() else user_project.expected_duration + } + print("Using project data: ", project_data) + + return project_data + + +def check_and_save_project(project_id, access_token, profile): + if not Project.objects.filter(project_id=project_id).exists(): + print(f"Project {project_id} not found. Fetching from API...") + fetch_and_save_project(project_id=project_id, access_token=access_token, profile=profile) + + +def fetch_and_save_project(project_id, access_token, profile): + + try: + url = f"https://{base_url}/userProjects/details/{project_id}" + print("using url: ", url) + headers = { + "X-auth-token": access_token, + } + payload = {} + + response = requests.request("POST", url, headers=headers, data=payload) + response_json = response.json() + print("response: ", response_json) + + + result = response_json.get("result") + project_status = result.get('status', '').upper() if result.get('status') else None + + + + project = Project.objects.create( + project_id=result.get("_id"), + recommended_for= json.dumps(result.get('recommendedFor')), + expected_title= result.get('title'), + categories= json.dumps(result.get('categories')), + expected_duration=result.get('duration'), + project_start_date=result.get('startDate'), + project_end_date=result.get('endDate'), + program_id= result.get('programId'), + program_name = result.get('programName'), + expected_problem_statement= result.get('programName'), + project_status=project_status, + project_source = result.get('source'), + expected_objective= result.get('description'), + author=profile, + # "generated_by": ProjectCreatedBy.EXPERT_VETTED + ) + tasks = result.get('tasks', []) + for task_data in tasks: + Task.objects.get_or_create( + project=project, + task_id=task_data.get('_id'), + defaults={"task_name": task_data.get('name', '')} + ) + print(f"Project {project_id} saved successfully.") + + + except Exception as e: + traceback.print_exc() + print(f"Failed to save project: {str(e)}") diff --git a/shikshalokam/utils/recommendation_utils.py b/shikshalokam/utils/recommendation_utils.py new file mode 100644 index 0000000..8cce69f --- /dev/null +++ b/shikshalokam/utils/recommendation_utils.py @@ -0,0 +1,37 @@ +import json +from shikshalokam.models import Project, ProjectCreatedBy, ProjectVernacular +from shikshalokam.serializer import ProjectSerializer + + +def get_expert_projects(language): + try: + print("Here") + projects = Project.objects.filter(generated_by=ProjectCreatedBy.EXPERT_VETTED) + project_serialized = ProjectSerializer(projects, many=True).data + for project in project_serialized: + project_id = project.get('project_id') + if project['generated_by'] == ProjectCreatedBy.EXPERT_VETTED: + print("language: ", language) + vernacular = ProjectVernacular.objects.filter( + project__project_id=project['project_id'], language=language + ).first() + print("vernacular: ", vernacular) + if vernacular: + if 'other_params' not in project: + project['other_params'] = {} + print("Going for project id: ", project_id) + vernacular_details = json.loads(vernacular.details) + project['actual_title'] = vernacular_details.get('title') + project['description'] = vernacular_details.get('description') + project['categories'] = vernacular_details.get('categories') + project['recommendedFor'] = vernacular_details.get('recommendedFor') + project['actual_problem_statement'] = vernacular_details.get('problemStatement') + project['other_params']['text'] = vernacular_details.get('text') + project['other_params']['impact'] = vernacular_details.get('impact') + project['other_params']['summary'] = vernacular_details.get('summary') + project['other_params']['template_author'] = vernacular_details.get('template_author') + + return project_serialized + except Exception as e: + print("Error: ", e) + return None diff --git a/shikshalokam/utils/story_utils.py b/shikshalokam/utils/story_utils.py new file mode 100644 index 0000000..bff46d1 --- /dev/null +++ b/shikshalokam/utils/story_utils.py @@ -0,0 +1,174 @@ +import json +import traceback +import os +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +from django.contrib.sessions.backends.db import SessionStore +from chatbot.llm_models.llm_script import handle_llama_model, handle_openai_model +from chatbot.models import Company, CompanyBot, Story, StoryStatusChoices +from chatbot.utils.story_utils_test import get_formatted_story +from shikshalokam.models import Project + +validate = URLValidator() + +url = os.getenv('LLAMA_BASE_URL') + 'v1/chat/completions' + + +def create_story_object(profile_id=None, model_to_use=None): + try: + projects = get_project_queryset(profile_id=profile_id) + company_bot = get_company_bot() + + for project in projects: + create_story_from_project(project=project, company_bot=company_bot, model_to_use=model_to_use) + + return {"message": "STORY CREATION SUCCESS"} + except Exception as e: + traceback.print_exc() + return {"error": "STORY CREATION ERROR"} + + +def create_story_from_project(project, company_bot, model_to_use): + messages = [{ + 'role': 'system', + 'content': get_story_prompt_context() + }, { + 'role': 'user', + 'content': generate_story_context(project) + }] + print("MESSAGES: ", messages) + if model_to_use in ['llama-normal', 'groq-llama', 'llama-finetune']: + response_json = handle_llama_model(messages=messages, max_token=4096, temperature=0.7, top_p=0.9, seed=2322, n=1) + print("\n\nResponse json: ", response_json) + story = parse_story_response(response_json=response_json, project=project) + else: + response_json = handle_openai_model(company_bot=company_bot, messages=messages, max_token=4096, temperature=0.0) + story = parse_story_response(response_json=response_json, project=project) + return story + + +def parse_story_response(response_json, project): + try: + session = generate_session_id() + + story = Story( + title=response_json['title'], + content=response_json['content'], + tweet=response_json['tweet'], + author=project.author, + session=session, + objective=response_json['objective'], + action_steps=response_json['action_steps'], + impact=response_json['impact'], + micro_improvement=response_json['micro_improvement'], + stage=StoryStatusChoices.COMPLETED, + other_params={'duration': response_json['duration']} + ) + story.save() + story.formatted_content = get_formatted_story(story) + story.save(update_fields=['formatted_content']) + + project.story = story + project.save() + return story + + except Exception as e: + traceback.print_exc() + raise Exception("Error creating story from response") + + +def generate_session_id(): + try: + session = SessionStore() + session.create() + return session.session_key + except Exception as e: + print('Exception is here') + print(e) + traceback.print_exc() + + +def is_url(value): + try: + validate(value) + return True + except ValidationError: + return False + + +def get_project_queryset(profile_id=None): + if profile_id: + return Project.objects.filter( + story=None, author=profile_id + ).select_related('author').prefetch_related('evidence', 'task') + return Project.objects.filter(story=None).select_related('author').prefetch_related('evidence', 'task') + + +def get_company_bot(): + try: + company = Company.objects.get(slug='shikshalokam') + company_bot = CompanyBot.objects.filter(company=company).first() + return company_bot + except Company.DoesNotExist: + raise Exception("Company not found") + + +def get_evidence_data(evidence): + evidence_link = evidence.evidence_link + if evidence_link and not is_url(evidence_link): + return {'remark': evidence.remark, 'evidence_text': evidence_link} + if is_url(evidence_link): + return {'remark': evidence.remark} + return None + + +def generate_story_context(project): + project_details = { + 'evidences': [data for evidence in project.evidence.all() if (data := get_evidence_data(evidence))], + } + task_details = [ + { + 'task_name': task.task_name if not is_url(task.task_name) else None, + 'observation_name': task.observation_name, + 'number_of_submission_observation': task.number_of_submission_observation, + } for task in project.task.all() + ] + + return f""" + Here are the details of a user project which need to be incorporated into the generated story. + Use these details to craft a narrative that reflects the essence of the project: + + - **Project Title**: {project.title} + - **Project Objective**: {project.objective} + - **Project Start Date**: {project.project_start_date} + - **Project End Date**: {project.project_end_date} + - **Project Duration**: {project.duration} + - **Project Evidences**: {json.dumps(project_details['evidences'], indent=4)} + - **Project Learning Resource Name**: {project.resource_name} + - **Project Learning Resource Link**: {project.resource_link} + - **Task Details**: {json.dumps(task_details, indent=4)} + """ + + + +def get_story_prompt_context(): + return """ + Use this project information from the user to create a detailed story that includes a title, content, tweet, + objective, action steps, impact, and the importance of the micro-improvement made through the project. + THINGS TO INCORPORATE: + 1. USE PRESENT TENSE + 2. DO NOT USE CLICHE BEGINNINGS + 3. DO NOT ADD FLUFF DO NOT USE FLOWERY LANGUAGE. + OUTPUT VALID JSON FORMAT: + { + "title": "Title of the story", + "duration": "Total time span of the project, from start to end", + "content": "Content of the story in more than 600 tokens", + "tweet": "Tweet for the story in less than 200 characters with minimum 5 hashtags", + "objective": "Objective of the micro improvement", + "action_steps": "5 Action steps taken by the user to implement the micro improvement", + "impact": "Impact created from this micro improvement", + "micro_improvement": "Why is this micro-improvement important" + } + """ + diff --git a/shikshalokam/utils/validation_utils.py b/shikshalokam/utils/validation_utils.py new file mode 100644 index 0000000..42ca8d5 --- /dev/null +++ b/shikshalokam/utils/validation_utils.py @@ -0,0 +1,228 @@ +from chatbot.llm_models.llm_script import handle_bedrock_model +import json_repair +import json +from jinja2 import Template + + +def validate_objective_utils(user_input, user_problem_statement, company_bot): + try: + prompt = company_bot.context + context_data = { + "objectives": user_input, + "problem_statement": user_problem_statement + } + template = Template(company_bot.tag_context) + tag_context = template.render(context_data) + + messages = [{ + 'role': 'user', + 'content': [{'text': f"{tag_context}"}] + }] + + prompt = [{'text': prompt}] + + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + response = handle_bedrock_model( + system_prompt=prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + tool_response = None + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + tool_response = tool_input + break + + if not tool_response and 'content' in response: + for content_block in response['content']: + if content_block.get('toolUse'): + tool_response = content_block['toolUse'].get('input', {}) + break + + if not tool_response: + tool_response = parse_llm_response(response) + + extracted_data = tool_response.pop("parameters", tool_response.pop("input", None)) + if extracted_data: + from shikshalokam.utils.action_list.action_parser import unwrap_tool_values + extracted_data = unwrap_tool_values(extracted_data) + tool_response = extracted_data + + if isinstance(tool_response.get('valid'), str): + tool_response['valid'] = tool_response['valid'].lower() == 'true' + + if isinstance(tool_response.get('problem_statement_in_scope'), str): + tool_response['problem_statement_in_scope'] = tool_response['problem_statement_in_scope'].lower() == 'true' + + if isinstance(tool_response.get('objectives_validation'), str): + import json + tool_response['objectives_validation'] = json.loads(tool_response['objectives_validation']) + for obj in tool_response['objectives_validation']: + if isinstance(obj.get('aligned'), str): + obj['aligned'] = obj['aligned'].lower() == 'true' + if isinstance(obj.get('within_scope'), str): + obj['within_scope'] = obj['within_scope'].lower() == 'true' + + return { + 'success': True, + 'data': tool_response + } + except Exception as e: + print("Got error : ", e) + return { + 'success': False, + 'error': str(e) + } + + +def validate_actions_utils(user_input, user_objective, problem_statement, company_bot): + try: + print('user_input: ', user_input) + prompt = company_bot.context + + context_data = { + "actionList": user_input, + "objective": user_objective, + "problem_statement": problem_statement + } + template = Template(company_bot.tag_context) + tag_context = template.render(context_data) + + messages = [{ + 'role': 'user', + 'content': [{'text': f"{tag_context}"}] + }] + + prompt = [{'text': prompt}] + + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + response = handle_bedrock_model( + system_prompt=prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + tool_response = None + + if 'output' in response: + content = response.get('output', {}).get('message', {}).get('content', []) + if content and isinstance(content, list): + for item in content: + if 'toolUse' in item: + tool_input = item['toolUse'].get('input', {}) + if tool_input: + tool_response = tool_input + break + + if not tool_response and 'content' in response: + for content_block in response['content']: + if content_block.get('toolUse'): + tool_response = content_block['toolUse'].get('input', {}) + break + + if not tool_response: + tool_response = parse_llm_response(response) + + extracted_data = tool_response.pop("parameters", tool_response.pop("input", None)) + if extracted_data: + from shikshalokam.utils.action_list.action_parser import unwrap_tool_values + extracted_data = unwrap_tool_values(extracted_data) + tool_response = extracted_data + + if isinstance(tool_response.get('valid'), str): + tool_response['valid'] = tool_response['valid'].lower() == 'true' + + if isinstance(tool_response.get('problem_statement_in_scope'), str): + tool_response['problem_statement_in_scope'] = tool_response['problem_statement_in_scope'].lower() == 'true' + + if isinstance(tool_response.get('objective_in_scope'), str): + tool_response['objective_in_scope'] = tool_response['objective_in_scope'].lower() == 'true' + + if isinstance(tool_response.get('actions_validation'), str): + import json + tool_response['actions_validation'] = json.loads(tool_response['actions_validation']) + for action in tool_response['actions_validation']: + if isinstance(action.get('aligned_with_objective'), str): + action['aligned_with_objective'] = action['aligned_with_objective'].lower() == 'true' + if isinstance(action.get('aligned_with_problem'), str): + action['aligned_with_problem'] = action['aligned_with_problem'].lower() == 'true' + if isinstance(action.get('within_scope'), str): + action['within_scope'] = action['within_scope'].lower() == 'true' + + return { + 'success': True, + 'data': tool_response + } + except Exception as e: + print("Got error : ", e) + return { + 'success': False, + 'error': str(e) + } + + +def validate_title_utils(user_input, user_objective, problem_statement, user_actions, company_bot): + try: + print('user_input: ', user_input) + prompt = company_bot.context + + context_data = { + "title": user_input, + "actionList": user_actions, + "objective": user_objective, + "problem_statement": problem_statement + } + template = Template(company_bot.tag_context) + tag_context = template.render(context_data) + + messages = [{ + 'role': 'user', + 'content': [{'text': f"{tag_context}"}] + }] + + prompt = [{'text': prompt}] + + import json_repair + tool_context = company_bot.tool_context + tool_context = json_repair.repair_json(tool_context, return_objects=True) + response = handle_bedrock_model( + system_prompt=prompt, messages=messages, model_name=company_bot.llm_model, + temperature=company_bot.bot_temperature, max_token=company_bot.max_token, company_bot=company_bot, + tools=tool_context, top_p=company_bot.filter_score, + ) + + parsed_response = parse_llm_response(response) + response = parsed_response.get('within_scope') + return response + except Exception as e: + print("Got error : ", e) + return False + + +def parse_llm_response(response): + if not response or not isinstance(response, dict): + return {} + + extracted_data = response.pop("parameters", response.pop("input", None)) + if extracted_data and isinstance(extracted_data, dict): + return extracted_data + + if isinstance(response, str): + try: + return json_repair.repair_json(response, return_objects=True) + except: + try: + return json.loads(response) + except: + return {} + + return response diff --git a/shikshalokam/utils/wishlist_utils.py b/shikshalokam/utils/wishlist_utils.py new file mode 100644 index 0000000..ddca161 --- /dev/null +++ b/shikshalokam/utils/wishlist_utils.py @@ -0,0 +1,65 @@ +import os +import requests + + +base_url = os.getenv("SHIKSHALOKAM_BASE_URL") + + +def add_project_wishlist(project, access_token): + url = f"https://{base_url}/wishlist/add/{project.id}" + + headers = { + "X-auth-token": access_token, + } + title = project.actual_title if project.actual_title else project.expected_title + objective = project.actual_objective if project.actual_objective else project.expected_objective + duration = project.actual_duration if project.actual_duration else project.expected_duration + request_body = { + "title": title, + "referenceFrom": project.generated_by, + "description": objective, + "metaInformation": { + "duration": duration + } + } + + print("req body: ", request_body) + + try: + response = requests.post(url, headers=headers, json=request_body) + response.raise_for_status() + json_response = response.json() + print("json_response: ", json_response) + + return json_response + + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the API call: {e}") + return None + except ValueError as e: + print(f"Validation error: {e}") + return None + + +def remove_project_wishlist(project, access_token): + url = f"https://{base_url}/wishlist/remove/{project.id}" + + headers = { + "X-auth-token": access_token, + } + + try: + response = requests.post(url, headers=headers) + response.raise_for_status() + json_response = response.json() + print("json_response: ", json_response) + + return json_response + + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the API call: {e}") + return None + except ValueError as e: + print(f"Validation error: {e}") + return None + diff --git a/shikshalokam/views/__init__.py b/shikshalokam/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shikshalokam/views/health_views.py b/shikshalokam/views/health_views.py new file mode 100644 index 0000000..e7e269c --- /dev/null +++ b/shikshalokam/views/health_views.py @@ -0,0 +1,14 @@ +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt +import time + +@csrf_exempt +@require_http_methods(["GET"]) +def health_check(request): + """Simple health check endpoint""" + return JsonResponse({ + 'status': 'healthy', + 'timestamp': int(time.time()), + 'service': 'shikshalokam-mohini' + }) diff --git a/shikshalokam/views/mitra_views.py b/shikshalokam/views/mitra_views.py new file mode 100644 index 0000000..a7d209a --- /dev/null +++ b/shikshalokam/views/mitra_views.py @@ -0,0 +1,728 @@ +import json +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from rest_framework.decorators import api_view +from rest_framework.response import Response +from django.http import JsonResponse +from chatbot.models import (CompanyBot, Voice, VoiceType, Profile, +BotVernacular, StoryMedia, MediaTypeChoices, SessionFlowName, ChatSession, +CompanyChat) +from chatbot.serializer.story_serializer import StoryMediaRetrieveSerializer +from chatbot.utils.chat_utils import get_guided_chat +from shikshalokam.utils.action_list.action_processor import post_process_actions_with_source +from shikshalokam.utils.action_list.action_steps_utils import generate_action_list_utils, generate_action_list_parallel +from asgiref.sync import async_to_sync +from shikshalokam.utils.mitra_base_utils import get_mitra_paraphrase_utils, generate_title_utils +from chatbot.utils.story_llama_utils import translate_field +from chatbot.utils.media_utils import upload_to_cloud +from chatbot.utils.shikshalokam_story_utils import update_story_pdf +from shikshalokam.models import Project +from shikshalokam.utils.objective_list.objective_processor import post_process_objectives_with_source +from shikshalokam.utils.objective_list.objective_utils import generate_objective_utils +from shikshalokam.utils.project_utils import update_project_status_utils +import json_repair +from shikshalokam.utils.validation_utils import validate_objective_utils, validate_actions_utils, validate_title_utils + +logger = logging.getLogger('django') + + +@api_view(['POST']) +def paraphrase_view(request): + try: + body = request.data + user_input = body.get('user_input') + session_id = body.get('session_id') + + if not session_id: + raise ValueError("Session ID is required") + + company_bot = CompanyBot.objects.get(route='/paraphrase') + + session_data = ChatSession.objects.values('language').get(session=session_id) + company_chats = CompanyChat.objects.filter(session=session_id).order_by('created_at') + language = session_data["language"] + + formatted_chats = get_guided_chat(company_bot=company_bot, company_chats=company_chats) + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + if language != 'en': + user_input = translate_field( + voice_provider=voice_provider, message_body=user_input, source_language=language, + target_language='en' + ) + print("user_translated_message: ", user_input) + + paraphrased_output = get_mitra_paraphrase_utils(messages=formatted_chats, company_bot=company_bot, session_id=session_id) + + print("\n\nParaphrased Output: ", paraphrased_output) + return Response({ + 'status': 'ok', + 'paraphrased_output': paraphrased_output + }, status=200) + except Exception as e: + logger.error(f"[paraphrase_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'message': str(e) + }, status=500) + + +@api_view(['POST']) +def generate_objectives_view(request): + error_message = "" + try: + body = request.data + user_input = body.get('user_input') + language = body.get('language') + profile_id = body.get('profile_id') + + logger.info( + f"[generate_objectives_view] Request received - user_input: {user_input}, language: {language}, " + f"profile_id: {profile_id}") + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/objective') + else: + company_bot = CompanyBot.objects.get(route='/objective') + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + if language != 'en': + logger.info(f"[generate_objectives_view] Translating user input from {language} to English") + user_input = translate_field( + voice_provider=voice_provider, message_body=user_input, source_language=language, + target_language='en' + ) + logger.info(f"[generate_objectives_view] Translated user input: {user_input}") + + logger.info(f"[generate_objectives_view] Calling generate_objective_utils") + gen_result = generate_objective_utils( + user_problem_statement=user_input, company_bot=company_bot + ) + logger.info( + f"[generate_objectives_view] Generation result status: {gen_result['status']}, " + f"objectives count: {len(gen_result.get('objective_list', []))}") + + if gen_result['objective_list'] == [] or not gen_result['objective_list']: + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message \ + else "Please try again!" + if voice_provider and language != 'en': + error_message = translate_field( + voice_provider=voice_provider, message_body=error_message, target_language=language + ) + logger.info(f"[generate_objectives_view] No objectives generated, error message: {error_message}") + + if gen_result['status'] != 'ok': + logger.error( + f"[generate_objectives_view] Generation failed with status: " + f"{gen_result['status']}, message: {gen_result.get('message')}") + return Response({ + 'status': gen_result['status'], + 'message': error_message, + 'objective_list': [], + 'chunks': None + }, status=gen_result['status_code']) + + objective_list = gen_result['objective_list'] + chunk_response = gen_result.get('chunks_response', None) + filtered_chunks = gen_result['filtered_chunks'] + + logger.info( + f"[generate_objectives_view] Objectives parsed: {len(objective_list)}, filtered chunks: {len(filtered_chunks)}") + + if not objective_list: + logger.info(f"[generate_objectives_view] Empty objective list, returning early") + return Response({ + 'status': 'ok', + 'message': error_message, + 'objective_list': [] + }, status=200) + + logger.info(f"[generate_objectives_view] Calling post_process_objectives_with_source") + post_result = post_process_objectives_with_source(objective_list, filtered_chunks, chunk_response) + logger.info(f"[generate_objectives_view] Post-processing result status: {post_result['status']}") + + if post_result['status'] != 'ok': + logger.error(f"[generate_objectives_view] Post-processing failed: {post_result.get('message')}") + return Response({ + 'status': post_result['status'], + 'message': error_message, + 'objective_list': [], + 'chunks': chunk_response + }, status=post_result['status_code']) + + objective_list = post_result['objective_list'] + logger.info(f"[generate_objectives_view] Post-processed objectives count: {len(objective_list)}") + + translated_list = None + if language != 'en': + logger.info(f"[generate_objectives_view] Translating objectives to {language}") + translated_list = translate_field( + voice_provider=voice_provider, message_body=json.dumps(objective_list), target_language=language, + source_language='en' + ) + if isinstance(translated_list, str): + try: + translated_list = json_repair.repair_json(translated_list, return_objects=True) + logger.info(f"[generate_objectives_view] Successfully parsed translated objectives") + except Exception as e: + logger.error(f"[generate_objectives_view] Error parsing translated objectives: {e}") + + if translated_list: + objective_list = translated_list + logger.info(f"[generate_objectives_view] Using translated objectives") + + logger.info(f"[generate_objectives_view] Returning {len(objective_list)} objectives successfully") + return Response({ + 'status': 'ok', + 'message': error_message, + 'objective_list': objective_list, + 'chunks': chunk_response + }, status=200) + + except Exception as e: + logger.error(f"[generate_objectives_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'message': error_message if error_message else "Please try again!", + 'objective_list': [], + 'chunks': None + }, status=500) + + +@api_view(['POST']) +def validate_objectives_view(request): + error_message = "Please try again!" + try: + body = request.data + user_input = body.get('user_input') + user_problem_statement = body.get('user_problem_statement') + language = body.get('language') + profile_id = body.get('profile_id') + + logger.info( + f"[validate_objectives_view] Request received - user_input: {user_input}, language: {language}, " + f"profile_id: {profile_id}") + + if isinstance(user_input, list): + user_input = " and ".join( + str(obj).strip() for obj in user_input if obj + ) + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/validate-objective') + else: + company_bot = CompanyBot.objects.get(route='/validate-objective') + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message else \ + "Please try again!" + + if language != 'en': + logger.info(f"[validate_objectives_view] Translating user input from {language} to English") + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + user_input = translate_field( + voice_provider=voice_provider, message_body=user_input, source_language=language, + target_language='en' + ) + user_problem_statement = translate_field( + voice_provider=voice_provider, message_body=user_problem_statement, source_language=language, + target_language='en' + ) + logger.info(f"[validate_objectives_view] Translated user input: {user_input}") + + logger.info(f"[validate_objectives_view] Calling validate_objective_utils") + validation_result = validate_objective_utils( + user_input=user_input, user_problem_statement=user_problem_statement, company_bot=company_bot + ) + + if not validation_result.get('success'): + logger.error(f"[validate_objectives_view] Validation utility failed: {validation_result.get('error')}") + return Response({ + 'status': 'error', + 'result': None, + 'error_message': error_message + }, status=500) + + llm_data = validation_result.get('data', {}) + valid = llm_data.get('valid', False) + + response_data = { + 'status': 'ok', + 'result': valid, + } + + if not valid: + response_data['error_message'] = llm_data.get('overall_message', error_message) + if 'objectives_validation' in llm_data: + response_data['validation_details'] = llm_data.get('objectives_validation') + if 'reason' in llm_data: + response_data['reason'] = llm_data.get('reason') + + logger.info(f"[validate_objectives_view] Returning validation result successfully") + return Response(response_data, status=200) + + except Exception as e: + logger.error(f"[validate_objectives_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'result': None, + 'error_message': error_message + }, status=500) + + +@api_view(['POST']) +def validate_actions_view(request): + error_message = "Please try again!" + try: + body = request.data + user_input = body.get('user_input') + user_objective = body.get('user_objective') + language = body.get('language') + problem_statement = body.get('problem_statement') + profile_id = body.get('profile_id') + + logger.info( + f"[validate_actions_view] Request received - user_input: {user_input}, user_objective: {user_objective}, " + f"language: {language}, profile_id: {profile_id}") + + if isinstance(user_objective, list): + user_objective = " and ".join( + str(obj).strip() for obj in user_objective if obj + ) + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/validate-action_list') + else: + company_bot = CompanyBot.objects.get(route='/validate-action_list') + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message else \ + "Please try again!" + + if language != 'en': + logger.info(f"[validate_actions_view] Translating inputs from {language} to English") + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + if isinstance(user_input, list): + user_input = [ + translate_field( + voice_provider=voice_provider, message_body=action, source_language=language, + target_language='en' + ) for action in user_input + ] + else: + user_input = translate_field( + voice_provider=voice_provider, message_body=user_input, source_language=language, + target_language='en' + ) + + user_objective = translate_field( + voice_provider=voice_provider, message_body=user_objective, source_language=language, + target_language='en' + ) + problem_statement = translate_field( + voice_provider=voice_provider, message_body=problem_statement, source_language=language, + target_language='en' + ) + + logger.info(f"[validate_actions_view] Translated user input: {user_input}") + logger.info(f"[validate_actions_view] Translated user objective: {user_objective}") + logger.info(f"[validate_actions_view] Translated problem statement: {problem_statement}") + + logger.info(f"[validate_actions_view] Calling validate_actions_utils") + validation_result = validate_actions_utils( + user_input=user_input, user_objective=user_objective, problem_statement=problem_statement, + company_bot=company_bot + ) + + if not validation_result.get('success'): + logger.error(f"[validate_actions_view] Validation utility failed: {validation_result.get('error')}") + return Response({ + 'status': 'error', + 'result': None, + 'error_message': error_message + }, status=500) + + llm_data = validation_result.get('data', {}) + valid = llm_data.get('valid', False) + + response_data = { + 'status': 'ok', + 'result': valid, + } + + if not valid: + response_data['error_message'] = llm_data.get('overall_message', error_message) + if 'actions_validation' in llm_data: + response_data['validation_details'] = llm_data.get('actions_validation') + if 'reason' in llm_data: + response_data['reason'] = llm_data.get('reason') + + logger.info(f"[validate_actions_view] Returning validation result successfully") + return Response(response_data, status=200) + + except Exception as e: + logger.error(f"[validate_actions_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'result': None, + 'error_message': error_message + }, status=500) + + +@api_view(['POST']) +def generate_action_list_view(request): + error_message = "Please try again!" + try: + body = request.data + user_problem_statement = body.get('user_problem_statement') + user_objective = body.get('user_objective') + language = body.get('language') + profile_id = body.get('profile_id') + + logger.info( + f"[generate_action_list_view] Request received - user_problem_statement: {user_problem_statement}, " + f"user_objective: {user_objective}, language: {language}, profile_id: {profile_id}") + + if isinstance(user_objective, str) and user_objective.strip() != "": + user_objective = [user_objective.strip()] + + elif not isinstance(user_objective, list): + raise ValueError("Invalid user_objective format") + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/action_list') + else: + company_bot = CompanyBot.objects.get(route='/action_list') + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message \ + else "Please try again!" + if voice_provider and language != 'en': + error_message = translate_field(voice_provider=voice_provider, message_body=error_message, target_language=language) + logger.info(f"[generate_action_list_view] No action plans generated, error message: {error_message}") + + # Call async fan-out from sync DRF view safely (production-safe under ASGI) + gen_result = async_to_sync(generate_action_list_parallel)( + query=user_problem_statement, + objectives=user_objective, + company_bot=company_bot, + language=language, + voice_provider=voice_provider + ) + + + action_list = gen_result['action_list'] + chunk_response = gen_result.get('chunks_response', None) + filtered_chunks = gen_result.get('filtered_chunks', []) + + if not action_list: + logger.info(f"[generate_action_list_view] Empty action list, returning early") + return Response({ + 'status': 'error', + 'message': error_message, + 'action_list': [] + }, status=500) + + post_result = post_process_actions_with_source(action_list, filtered_chunks, chunk_response) + + if post_result['status'] != 'ok': + logger.error(f"[generate_action_list_view] Post-processing failed: {post_result.get('message')}") + return Response({ + 'status': post_result['status'], + 'message': error_message, + 'action_list': [] + }, status=post_result['status_code']) + + action_list = post_result['action_list'] + + if language != 'en': + logger.info(f"[generate_action_list_view] Translating action steps to {language}") + for idx, action_item in enumerate(action_list): + action_steps = action_item.get('actionSteps', []) + if action_steps: + translated_steps = translate_field( + voice_provider=voice_provider, message_body=json.dumps(action_steps), target_language=language, + source_language='en' + ) + + if isinstance(translated_steps, str): + try: + translated_steps = json_repair.repair_json(translated_steps, return_objects=True) + logger.info( + f"[generate_action_list_view] Successfully translated action steps for plan {idx + 1}") + except Exception as e: + logger.error( + f"[generate_action_list_view] Error parsing translated steps for plan {idx + 1}: {e}") + translated_steps = action_steps + + action_item['actionSteps'] = translated_steps + + # logger.info(f"[generate_action_list_view] Returning {len(action_list)} action plans successfully") + return Response({ + 'status': 'ok', + 'message': "Response generated successfully", + 'action_list': action_list + }, status=200) + + except Exception as e: + logger.error(f"[generate_action_list_view_v2] Unhandled exception: {str(e)}", exc_info=True) + return JsonResponse({ + 'status': 'error', + 'message': error_message if error_message else "Please try again!" + }, status=500) + + +@api_view(['POST']) +def generate_title_view(request): + try: + body = request.data + user_problem_statement = body.get('user_problem_statement') + user_objective = body.get('user_objective') + user_action_list = body.get('user_action_list') + language = body.get('language') + profile_id = body.get('profile_id') + + logger.info( + f"[generate_title_view] Request received - user_problem_statement: {user_problem_statement}, " + f"user_objective: {user_objective}, language: {language}, profile_id: {profile_id}") + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/title') + else: + company_bot = CompanyBot.objects.get(route='/title') + + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + if language != 'en': + logger.info(f"[generate_title_view] Translating inputs from {language} to English") + user_problem_statement = translate_field( + voice_provider=voice_provider, message_body=user_problem_statement, source_language=language, + target_language='en' + ) + user_objective = translate_field( + voice_provider=voice_provider, message_body=user_objective, source_language=language, + target_language='en' + ) + + if isinstance(user_action_list, list): + user_action_list = user_action_list[0] + user_action_list = user_action_list.get('actionSteps') + logger.info(f"[generate_title_view] Extracting action steps from list") + user_action_list = [ + translate_field( + voice_provider=voice_provider, message_body=action, source_language=language, + target_language='en' + ) for action in user_action_list + ] + else: + user_action_list = translate_field( + voice_provider=voice_provider, message_body=user_action_list, source_language=language, + target_language='en' + ) + + logger.info(f"[generate_title_view] Translated problem statement: {user_problem_statement}") + logger.info(f"[generate_title_view] Translated objective: {user_objective}") + logger.info(f"[generate_title_view] Translated action list: {user_action_list}") + + input_data = { + "user_problem_statement": user_problem_statement, + "user_objective": user_objective, + "user_action_list": user_action_list + } + + logger.info(f"[generate_title_view] Calling generate_title_utils") + title = generate_title_utils(input_data=input_data, company_bot=company_bot) + logger.info(f"[generate_title_view] Generated title: {title}") + + if language != 'en': + logger.info(f"[generate_title_view] Translating title to {language}") + title = translate_field( + voice_provider=voice_provider, message_body=title, target_language=language, + source_language='en' + ) + logger.info(f"[generate_title_view] Translated title: {title}") + + logger.info(f"[generate_title_view] Returning title successfully") + return Response({ + 'status': 'ok', + 'title': title + }, status=200) + + except Exception as e: + logger.error(f"[generate_title_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'title': '' + }, status=500) + + +@api_view(['POST']) +def validate_title_view(request): + try: + body = request.data + user_actions = body.get('user_actions') + user_objective = body.get('user_objective') + language = body.get('language') + problem_statement = body.get('problem_statement') + user_input = body.get('user_input') + profile_id = body.get('profile_id') + + logger.info( + f"[validate_title_view] Request received - user_input: {user_input}, user_objective: {user_objective}, " + f"language: {language}, profile_id: {profile_id}") + + profile = Profile.objects.filter(id=profile_id).first() + if profile: + company_bot = CompanyBot.objects.get(company=profile.company, route='/validate-title') + else: + company_bot = CompanyBot.objects.get(route='/validate-title') + + bot_vernacular = BotVernacular.objects.filter(company_bot=company_bot, language=language).first() + error_message = bot_vernacular.error_message if bot_vernacular and bot_vernacular.error_message else \ + "Please try again!" + + if language != 'en': + logger.info(f"[validate_title_view] Translating inputs from {language} to English") + voice_provider = Voice.objects.filter( + company_bot=company_bot, type=VoiceType.TextToText, language=language + ).first() + + if isinstance(user_actions, list): + user_actions = user_actions[0] + user_actions = user_actions.get('actionSteps') + logger.info(f"[validate_title_view] Extracting action steps from list") + user_actions = [ + translate_field( + voice_provider=voice_provider, message_body=action, source_language=language, + target_language='en' + ) for action in user_actions + ] + else: + user_actions = translate_field( + voice_provider=voice_provider, message_body=user_actions, source_language=language, + target_language='en' + ) + + user_objective = translate_field( + voice_provider=voice_provider, message_body=user_objective, source_language=language, + target_language='en' + ) + problem_statement = translate_field( + voice_provider=voice_provider, message_body=problem_statement, source_language=language, + target_language='en' + ) + user_input = translate_field( + voice_provider=voice_provider, message_body=user_input, source_language=language, + target_language='en' + ) + + logger.info(f"[validate_title_view] Translated user input: {user_input}") + logger.info(f"[validate_title_view] Translated user actions: {user_actions}") + logger.info(f"[validate_title_view] Translated user objective: {user_objective}") + logger.info(f"[validate_title_view] Translated problem statement: {problem_statement}") + + logger.info(f"[validate_title_view] Calling validate_title_utils") + response = validate_title_utils( + user_input=user_input, user_objective=user_objective, problem_statement=problem_statement, + user_actions=user_actions, company_bot=company_bot + ) + logger.info(f"[validate_title_view] Validation result: {response}") + + logger.info(f"[validate_title_view] Returning validation result successfully") + return Response({ + 'status': 'ok', + 'result': response, + 'error_message': error_message + }, status=200) + + except Exception as e: + logger.error(f"[validate_title_view] Unhandled exception: {str(e)}", exc_info=True) + return Response({ + 'status': 'error', + 'result': None, + 'error_message': "Please try again!" + }, status=500) + +@api_view(['POST']) +def update_project_status_view(request): + try: + body = request.data + access_token = body.get('access_token') + project_id = body.get('project_id') + flow = body.get('flow') + status = body.get("status", "completed") + + logger.info( + f"[update_project_status_view] Request received - project_id: {project_id}, flow: {flow}, status: {status}") + + if not project_id: + logger.info(f"[update_project_status_view] No project_id provided, skipping") + return JsonResponse( + {"message": "Project ID not provided. Skipping this API call."}, + status=200 + ) + + session = None + project = Project.objects.filter(project_id=project_id).first() + + if project: + logger.info(f"[update_project_status_view] Project found: {project}") + if project.story: + session = project.story.session + logger.info(f"[update_project_status_view] Session: {session}") + + if (project and project.story and flow in [SessionFlowName.Reflection, SessionFlowName.GuestMiStory] and + status == 'completed'): + logger.info(f"[update_project_status_view] Processing story media for project") + story_media_objects = StoryMedia.objects.filter( + story=project.story, include_in_story=True + ).exclude(media_type=MediaTypeChoices.PDF) + serialized_data = StoryMediaRetrieveSerializer(story_media_objects, many=True).data + logger.info(f"[update_project_status_view] Found {len(serialized_data)} story media objects") + + with ThreadPoolExecutor() as executor: + futures = [executor.submit( + upload_to_cloud, session_value=session, access_token=access_token, instance=story_obj, story=None + ) for story_obj in serialized_data] + + for future in as_completed(futures): + future.result() + + logger.info(f"[update_project_status_view] Story media uploaded, updating story PDF") + update_story_pdf(is_edit_story=True, session=session, access_token=access_token, flow=flow) + + logger.info(f"[update_project_status_view] Calling update_project_status_utils") + response = update_project_status_utils( + project_id=project_id, access_token=access_token, status=status + ) + logger.info(f"[update_project_status_view] Update response: {response}") + + logger.info(f"[update_project_status_view] Returning response successfully") + return JsonResponse(response.get("message"), status=response.get("status"), safe=False) + + except Exception as e: + logger.error(f"[update_project_status_view] Unhandled exception: {str(e)}", exc_info=True) + return JsonResponse({'message': f"{e}"}, status=500) diff --git a/shikshalokam/views/profile_views.py b/shikshalokam/views/profile_views.py new file mode 100644 index 0000000..5a6b9d4 --- /dev/null +++ b/shikshalokam/views/profile_views.py @@ -0,0 +1,28 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from chatbot.utils.elevate.profile_utils import handle_elevate_profile + + +@api_view(['GET']) +def read_elevate_profile(request): + access_token = request.headers.get('X-auth-token') + print("Access token: ", access_token) + + if not access_token: + return Response({ + 'status': 'error', + 'message': 'Access token is required.' + }, status=400) + + profile_details = handle_elevate_profile(access_token=access_token) + + if not profile_details or not profile_details.get('profileid'): + return Response({ + 'status': 'error', + 'message': 'Failed to fetch or create profile from Elevate.' + }, status=500) + + return Response({ + 'status': 'ok', + 'profile_details': profile_details + }, status=200) diff --git a/shikshalokam/views/project_views.py b/shikshalokam/views/project_views.py new file mode 100644 index 0000000..b89b46d --- /dev/null +++ b/shikshalokam/views/project_views.py @@ -0,0 +1,230 @@ +import os +from jwt import ExpiredSignatureError, InvalidTokenError +from django.conf import settings +import traceback +import django_filters +from rest_framework import generics +from django.http import JsonResponse +from chatbot.models import Profile +from chatbot.utils.shikshalokam_mitra_utils import create_project_utils, import_project_from_library_utils +from shikshalokam.models import Project, Task, Evidence +from shikshalokam.scripts.template_ingestion import ingest_project_template, ingest_task_data +from shikshalokam.serializer import ProjectSerializer +from rest_framework.decorators import api_view +from django.db import transaction +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.response import Response +import jwt +from rest_framework.exceptions import AuthenticationFailed + +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + +class ProjectListCreateView(generics.ListCreateAPIView): + queryset = Project.objects.prefetch_related('task__evidence', 'evidence', 'learning_resource').select_related( + 'project_template__category', 'author' + ).all() + serializer_class = ProjectSerializer + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] + filterset_fields = ['id', 'project_id', 'program_id'] + + + def get_user_from_token(self, request): + access_token = request.headers.get("X-auth-token") + + if not access_token: + raise AuthenticationFailed("Access token missing") + + try: + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + + user_id = decoded.get("data", {}).get("id") + + if not user_id: + raise AuthenticationFailed("Invalid token: user_id missing") + + return Profile.objects.get(userid=user_id) + + except ExpiredSignatureError: + raise AuthenticationFailed("Token has expired") + + except InvalidTokenError: + raise AuthenticationFailed("Invalid token") + + except Profile.DoesNotExist: + raise AuthenticationFailed("User not found") + + + def list(self, request, *args, **kwargs): + user = self.get_user_from_token(request) + + queryset = self.filter_queryset(self.get_queryset()) + + if queryset.count() == 1: + serializer = self.get_serializer(queryset.first(), context={'request': request, 'author': user}) + return Response(serializer.data) + + serializer = self.get_serializer(queryset, many=True, context={'request': request, 'author': user}) + return Response(serializer.data) + + +@api_view(['POST']) +@transaction.atomic +def duplicate_project_view(request): + body = request.query_params + + project_template_id = request.data.get('projectTemplateId') + program_name = request.data.get('programName') + program_id = request.data.get('programId') + project_id = body.get("id") + + access_token = request.headers.get("X-auth-token") + + try: + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + user_id = decoded.get("data", {}).get("id") + + if not user_id: + return JsonResponse({'message': "Invalid access token"}, status=401) + + except ExpiredSignatureError: + return JsonResponse({'message': "Token expired"}, status=401) + + except InvalidTokenError: + return JsonResponse({'message': "Invalid token"}, status=401) + + + try: + new_author = Profile.objects.get(userid=user_id) + except Profile.DoesNotExist: + return JsonResponse({'message': f"Profile not found for userid: {user_id}"}, status=404) + + try: + original_project = Project.objects.prefetch_related('task__evidence').get(id=project_id) + user_action_steps = [task.task_name for task in original_project.task.all()] + + if project_template_id: + response = import_project_from_library_utils( + access_token=access_token, program_name=program_name, project_template_id=project_template_id, + program_id=program_id + ) + else: + if original_project.actual_problem_statement: + user_problem_statement = original_project.actual_problem_statement + project_title = original_project.actual_title + project_duration_weeks = original_project.actual_duration + project_objective = original_project.actual_objective + print("Actual_duration_weeks: ", project_duration_weeks) + else: + user_problem_statement = original_project.expected_problem_statement + project_title = original_project.expected_title + project_duration_weeks = original_project.expected_duration + project_objective = original_project.expected_objective + print("Expected_duration_weeks: ", project_duration_weeks) + print("project_duration_weeks: ", project_duration_weeks) + response = create_project_utils( + access_token=access_token, user_problem_statement=user_problem_statement, + user_action_steps=user_action_steps, project_title=project_title, + project_duration_weeks=project_duration_weeks, original_project=original_project, + project_objective=project_objective, status='started' + ) + + print("response: ", response) + if not response: + return JsonResponse({'message': 'Error in Shikshalokam Project API'}, status=500, safe=False) + + temp_project_source = None + if project_template_id: + project_id = response.get('result', {}).get('_id') + program_id = response.get('result', {}).get('programId') + if original_project.template_id: + temp_project_source = { + "projectTemplateId": original_project.template_id + } + else: + project_id = response.get('projectId') + program_id = response.get('programId') + temp_project_source = response.get('chunks') + + print(f"project_id: {project_id}") + print("Got Chunks: ", temp_project_source) + if not project_id: + return JsonResponse({'message': 'Error in Shikshalokam Project API'}, status=500, safe=False) + + duplicate_project, created = Project.objects.get_or_create( + project_id=project_id, + defaults={ + **{ + field.name: getattr(original_project, field.name) + for field in Project._meta.fields + if field.name not in ['id', 'author', 'project_id', 'program_id', 'created_at', 'updated_at', + 'history', 'program_name', 'generated_by', 'project_source'] + }, + 'program_id': program_id, + 'program_name': program_name, + 'author': new_author, + 'project_source': response.get('chunks') + } + ) + if created: + for original_task in original_project.task.all(): + duplicate_task = Task.objects.create( + **{ + field.name: getattr(original_task, field.name) + for field in Task._meta.fields + if field.name not in ['id', 'project', 'created_at', 'updated_at', 'history', 'created_by'] + }, + project=duplicate_project + ) + + for original_evidence in original_task.evidence.all(): + Evidence.objects.create( + **{ + field.name: getattr(original_evidence, field.name) + for field in Evidence._meta.fields + if field.name not in ['id', 'created_at', 'updated_at', 'history', 'created_by'] + }, + task=duplicate_task + ) + + serialized_project = ProjectSerializer(duplicate_project).data + print("Serialized project: ", serialized_project) + if project_template_id: + return JsonResponse(response, status=200, safe=False) + else: + return JsonResponse(response.get('original_response'), status=200, safe=False) + + except ObjectDoesNotExist: + traceback.print_exc() + + return JsonResponse({'message': f"Project with id {project_id} does not exist."}, status=404, safe=False) + + except Exception as e: + traceback.print_exc() + + return JsonResponse({ + 'message': f"An error occurred while duplicating the project: {str(e)}" + }, status=500, safe=False) + + +@api_view(['POST']) +def project_ingestion_view(request): + body = request.data + key = body.get('key') + file_path = body.get('file_path') + + if key == 'TASK': + ingest_task_data(file_path) + elif key == 'PROJECT': + ingest_project_template(file_path) + + return JsonResponse({ + 'message': f"Done" + }, status=200, safe=False) diff --git a/shikshalokam/views/story_views.py b/shikshalokam/views/story_views.py new file mode 100644 index 0000000..c6c2e86 --- /dev/null +++ b/shikshalokam/views/story_views.py @@ -0,0 +1,30 @@ +import traceback + +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from shikshalokam.utils.story_utils import create_story_object + + +@api_view(['POST']) +def create_story_from_project_view(request): + try: + profile_id = request.data['profile_id'] + model = request.data.get('model', None) + if profile_id is None: + response_json = create_story_object() + return Response({ + 'status': 'ok', + 'message': 'Story created For all projects', + 'response_json': response_json + }, status=200) + else: + response_json = create_story_object(profile_id=profile_id, model_to_use=model) + return Response({ + 'status': 'ok', + 'message': f'Story created for profile_id: {profile_id}', + 'response_json': response_json + }, status=200) + except Exception as e: + print(e) + traceback.print_exc() diff --git a/shikshalokam/views/wishlist_views.py b/shikshalokam/views/wishlist_views.py new file mode 100644 index 0000000..264953f --- /dev/null +++ b/shikshalokam/views/wishlist_views.py @@ -0,0 +1,135 @@ +import os +from jwt import ExpiredSignatureError, InvalidTokenError +from django.http import JsonResponse + +from chatbot.models import Profile +from shikshalokam.models import Project, ProjectWishlist +from rest_framework.decorators import api_view +from shikshalokam.utils.wishlist_utils import add_project_wishlist, remove_project_wishlist +import jwt + +PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + +@api_view(['POST']) +def wishlist_project_view(request): + try: + body = request.data + in_wishlist = body.get('in_wishlist') + project_id = body.get('id') + access_token = request.headers.get("X-auth-token") + in_wishlist = bool(in_wishlist) + + if project_id is None or in_wishlist is None: + return JsonResponse({ + 'error': 'Both "id" and "in_wishlist" fields are required.' + }, status=400, safe=False) + + if not access_token: + return JsonResponse( + {'error': 'Access token missing'}, + status=401, + safe=False + ) + + try: + decoded = jwt.decode( + access_token, + PUBLIC_KEY, + algorithms=["HS256"] + ) + + user_id = decoded.get("data", {}).get("id") + + if not user_id: + return JsonResponse( + {'error': 'Invalid token'}, + status=401, + safe=False + ) + + profile = Profile.objects.get(userid=user_id) + + except ExpiredSignatureError: + return JsonResponse( + {'error': 'Token expired'}, + status=401, + safe=False + ) + + except InvalidTokenError: + return JsonResponse( + {'error': 'Invalid token'}, + status=401, + safe=False + ) + + except Profile.DoesNotExist: + return JsonResponse( + {'error': 'User not found'}, + status=404, + safe=False + ) + + + project = Project.objects.filter(id=project_id).first() + if not project: + return JsonResponse({ + 'error': f'Project with id {project_id} not found.' + }, status=404, safe=False) + + project_in_wishlist = ProjectWishlist.objects.filter(author=profile, project=project).exists() + + if in_wishlist: + if project_in_wishlist: + return JsonResponse({ + 'error': 'error', + 'details': "Project templates already exist in wishlist." + }, status=500, safe=False) + else: + try: + json_response = add_project_wishlist(project=project, access_token=access_token) + if json_response.get('status') == 200: + pw = ProjectWishlist.objects.create( + author=profile, + project=project + ) + print("Adeed project from our db: ", pw.id) + return JsonResponse({ + 'message': json_response.get('message'), + 'status': json_response.get('status') + }, status=200, safe=False) + except Exception as e: + return JsonResponse({ + 'error': 'An unexpected error occurred.', + 'details': str(e) + }, status=500, safe=False) + else: + if project_in_wishlist: + try: + json_response = remove_project_wishlist(project=project, access_token=access_token) + if json_response.get('status') == 200: + ProjectWishlist.objects.filter( + author=profile, + project=project + ).delete() + print("removed project from our db.") + return JsonResponse({ + 'message': json_response.get('message'), + 'status': json_response.get('status') + }, status=200, safe=False) + except Exception as e: + return JsonResponse({ + 'error': 'An unexpected error occurred.', + 'details': str(e) + }, status=500, safe=False) + else: + return JsonResponse({ + 'error': 'error', + 'details': "Project templates does not exist in wishlist." + }, status=500, safe=False) + + except Exception as e: + return JsonResponse({ + 'error': 'An unexpected error occurred.', + 'details': str(e) + }, status=500, safe=False) diff --git a/shikshalokam_mohini/__init__.py b/shikshalokam_mohini/__init__.py new file mode 100644 index 0000000..f059bf0 --- /dev/null +++ b/shikshalokam_mohini/__init__.py @@ -0,0 +1,3 @@ +from .celery_config import app as celery_app + +__all__ = ['celery_app'] diff --git a/shikshalokam_mohini/asgi.py b/shikshalokam_mohini/asgi.py new file mode 100644 index 0000000..5097cd4 --- /dev/null +++ b/shikshalokam_mohini/asgi.py @@ -0,0 +1,37 @@ +""" +ASGI config for shikshalokam_mohini project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + +django_asgi_app = get_asgi_application() + +# Import AFTER Django initialization +import django +django.setup() # Extra insurance that Django is fully set up +import chatbot.routing + +from channels.sessions import CookieMiddleware, SessionMiddleware +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator + + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack( + CookieMiddleware(SessionMiddleware(URLRouter(chatbot.routing.websocket_urlpatterns))) + ) + ), + } +) diff --git a/shikshalokam_mohini/celery_config.py b/shikshalokam_mohini/celery_config.py new file mode 100644 index 0000000..0cb3182 --- /dev/null +++ b/shikshalokam_mohini/celery_config.py @@ -0,0 +1,28 @@ +import os +from celery import Celery + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + +REDIS_HOST = os.environ.get('REDIS_HOST', "localhost") +REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) + +app = Celery('shikshalokam_mohini', backend=f'redis://{REDIS_HOST}:{REDIS_PORT}', broker=f'redis://{REDIS_HOST}:{REDIS_PORT}') +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks([ + 'chatbot.celery_tasks.shikshalokam_bedrock_tasks', + 'chatbot.celery_tasks.one_shot_bedrock_tasks', + 'chatbot.celery_tasks.chaupal_tasks', + 'chatbot.celery_tasks.common_chat_tasks', + 'chatbot.celery_tasks.reflection_bedrock_tasks', + 'chatbot.celery_tasks.mitra_bedrock_tasks', + 'chatbot.utils.story_utils', + 'chatbot.celery_tasks.guided_guest_tasks', + 'chatbot.celery_tasks.oneshot_guest_tasks', + 'chatbot.celery_tasks.ptm_report_tasks', + 'chatbot.celery_tasks.knowledge_service.tag_tasks', + 'chatbot.celery_tasks.knowledge_service.media_tasks', + 'chatbot.celery_tasks.flow_tasks', + 'chatbot.celery_tasks.free_flow_tasks', + 'chatbot.celery_tasks.post_processing_tasks' +]) diff --git a/shikshalokam_mohini/settings.py b/shikshalokam_mohini/settings.py new file mode 100644 index 0000000..a144bf5 --- /dev/null +++ b/shikshalokam_mohini/settings.py @@ -0,0 +1,495 @@ +""" +Django settings for Mohini project. + +Generated by 'django-admin startproject' using Django 4.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" +import json +import os +import re +from datetime import timedelta +import sentry_sdk +from dotenv import load_dotenv +from socket import gethostbyname +from socket import gethostname + +load_dotenv() + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +CODE_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +LOGGING_DIR = CODE_BASE_DIR + '/logs' + +def load_secrets(): + paths_to_try = [ + '/home/ubuntu/shikshalokam-mohini-service/config/secrets.json', + os.path.join(CODE_BASE_DIR, "config/secrets.json"), + os.path.join(os.getcwd(), "config/secrets.json") + ] + + for path in paths_to_try: + print(f"[DEBUG] Trying secrets file at: {path}") + try: + with open(path, 'r') as f: + print(f"[DEBUG] Loaded secrets from: {path}") + return json.load(f), path + except FileNotFoundError: + print(f"[DEBUG] File not found at: {path}") + continue + + raise FileNotFoundError(f"secrets.json not found in any of: {paths_to_try}") + + +SECRETS, SECRETS_JSON_PATH = load_secrets() + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-#7!xudzh7f@!yih9^l5d)my*$7=^j-@i#qla(k1mae5u(qs_c^' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True if os.getenv('SETTINGS_DEBUG') == 'True' else False + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', + 'access-control-allow-origin', + 'x-auth-token' +] + +CORS_ALLOW_CREDENTIALS = True + +CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS').split(',') + +CORS_ALLOWED_METHODS = [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS' # Include the OPTIONS method for preflight requests +] + +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',') + +# ALLOWED_HOSTS.append(gethostbyname(gethostname())) + + +# Application definition + +INSTALLED_APPS = [ + 'rangefilter', + 'observability', + 'daphne', + 'chatbot', + 'shikshalokam', + 'jazzmin', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tailwind', + 'django_extensions', + 'rest_framework', + 'corsheaders', + 'django_countries', + 'django_s3_storage', + 'import_export', + 'simple_history', + 'storages', + 'django_crontab', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + ## extra middlwares + 'chatbot.middlewares.VerifyAuthToken' +] + +if DEBUG: + MIDDLEWARE.extend([ + 'querycount.middleware.QueryCountMiddleware' + ]) + + +ROOT_URLCONF = 'shikshalokam_mohini.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +QUERYCOUNT = { + 'DISPLAY_DUPLICATES': 5, +} + +WSGI_APPLICATION = 'shikshalokam_mohini.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + 'OPTIONS': { + 'sslmode': os.getenv('PG_SSL_MODE'), + 'sslrootcert': os.getenv('PG_SSL_ROOT_CERT') + }, + }, +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'Asia/Kolkata' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = os.getenv('STATIC_ROOT', '/var/www/shikshalokam/static/') +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static') +] + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +ASGI_APPLICATION = 'shikshalokam_mohini.asgi.application' + +REDIS_HOST = os.environ.get('REDIS_HOST', "127.0.0.1") +REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) +REDIS_USE_SSL = os.environ.get('REDIS_USE_SSL', 'false').lower() == 'true' + +# Build Redis connection URL with SSL support +REDIS_PROTOCOL = 'rediss' if REDIS_USE_SSL else 'redis' +REDIS_URL = f'{REDIS_PROTOCOL}://{REDIS_HOST}:{REDIS_PORT}' + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [REDIS_URL], + "capacity": 100000, + "channel_capacity": { + "http.request": 50000, + "http.response!*": 50000, + re.compile(r"^websocket.send\!.+"): 100000, + } + }, + }, +} + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': f'{REDIS_URL}/1', # Use database 1 (different from channels) + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'CONNECTION_POOL_KWARGS': { + 'max_connections': 50, + 'retry_on_timeout': True, + 'socket_connect_timeout': 5, + 'socket_timeout': 5, + 'health_check_interval': 30, + }, + 'SERIALIZER': 'django_redis.serializers.pickle.PickleSerializer', + 'COMPRESSOR': 'django_redis.compressors.zlib.ZlibCompressor', + }, + 'TIMEOUT': 7200, + } +} + +# TAILWIND_APP_NAME = "theme" + +INTERNAL_IPS = [ + "127.0.0.1", +] +DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000 +SECURE_CROSS_ORIGIN_OPENER_POLICY = None + +CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "https://*.shikshalokam.org,https://*.127.0.0.1,https://*.gritworks.ai,http://localhost:3000").split(',') + +STORAGE_CLOUD_PROVIDER = os.environ.get('STORAGE_CLOUD_PROVIDER', 'AWS').upper() + +# Storage backend configurations +STORAGE_BACKENDS = { + 'AWS': { + 'backend': 'storages.backends.s3boto3.S3Boto3Storage', + 'options': { + 'bucket_name': os.getenv('S3_BUCKET_NAME'), + 'region_name': os.getenv('AWS_REGION'), + }, + }, + 'GCP': { + 'backend': 'storages.backends.gcloud.GoogleCloudStorage', + 'options': { + 'bucket_name': os.getenv('GCS_BUCKET_NAME'), + 'project_id': os.getenv('GCP_PROJECT_ID'), + 'credentials': os.getenv('GCP_CREDENTIALS_PATH'), + }, + }, + 'AZURE': { + 'backend': 'storages.backends.azure_storage.AzureStorage', + 'options': { + 'account_name': os.getenv('AZURE_ACCOUNT_NAME'), + 'account_key': os.getenv('AZURE_ACCOUNT_KEY'), + 'azure_container': os.getenv('AZURE_CONTAINER_NAME'), + }, + }, + 'LOCAL': { + 'backend': 'django.core.files.storage.FileSystemStorage', + 'options': { + 'location': os.path.join(BASE_DIR, 'media'), + 'base_url': '/media/', + }, + }, +} + +# Configure storage based on provider +if STORAGE_CLOUD_PROVIDER in STORAGE_BACKENDS: + config = STORAGE_BACKENDS[STORAGE_CLOUD_PROVIDER] + + if STORAGE_CLOUD_PROVIDER == 'LOCAL': + MEDIA_ROOT = config['options']['location'] + MEDIA_URL = config['options']['base_url'] + + STORAGES = { + "default": { + "BACKEND": config['backend'], + "OPTIONS": config['options'], + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + else: + # For cloud storage (AWS/GCP/Azure), still set MEDIA_ROOT and MEDIA_URL + # These are needed for our storage handler to work correctly + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + MEDIA_URL = '/media/' + + STORAGES = { + "default": { + "BACKEND": config['backend'], + "OPTIONS": config['options'], + }, + "staticfiles": { + "BACKEND": config['backend'], + "OPTIONS": config['options'], + }, + } +else: + raise ValueError( + f"Unsupported STORAGE_CLOUD_PROVIDER: {STORAGE_CLOUD_PROVIDER}. " + f"Supported values: {', '.join(STORAGE_BACKENDS.keys())}" + ) + + +# AWS Configurations +AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Your Project API', + 'DESCRIPTION': 'Your project description', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=7), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=7), + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_SERIALIZER': 'chatbot.serializers.ProfileTokenObtainPairSerializer' +} + +SESSION_COOKIE_HTTPONLY = False +SESSION_COOKIE_SAMESITE = None + + +sentry_sdk.init( + dsn=os.getenv('SENTRY_DSN'), + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + traces_sample_rate=1.0, + # Set profiles_sample_rate to 1.0 to profile 100% + # of sampled transactions. + # We recommend adjusting this value in production. + profiles_sample_rate=1.0, +) + +JAZZMIN_SETTINGS = { + 'site_title': 'Shikshalokam', + 'site_header': 'Shikshalokam', + 'site_brand': ' ', + 'site_logo': 'fe-images/PNG/Shikshalokam/shikshalokam-logo.png', + 'login_logo': 'fe-images/PNG/Shikshalokam/shikshalokam-logo.png', + 'site_logo_classes': 'img-fluid', + 'welcome_sign': '', + 'copyright': 'Shikshalokam', + 'show_ui_builder': False, + 'changeform_format': 'single', + +} + +JAZZMIN_UI_TWEAKS = { + 'navbar_fixed': True +} + +REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100, + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'debug_file': { + 'level': 'DEBUG', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOGGING_DIR, 'debug.log'), + 'when': 'midnight', # Rotate daily + 'interval': 1, # 1 day interval + 'backupCount': 30, # Keep 30 backup copies + 'formatter': 'verbose', + "delay": False, + }, + 'info_file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOGGING_DIR, 'info.log'), + 'when': 'midnight', # Rotate daily + 'interval': 1, # 1 day interval + 'backupCount': 30, # Keep 30 backup copies + 'formatter': 'verbose', + "delay": False, + }, + 'warning_file': { + 'level': 'WARNING', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOGGING_DIR, 'error.log'), + 'when': 'midnight', # Rotate daily + 'interval': 1, # 1 day interval + 'backupCount': 30, # Keep 30 backup copies + 'formatter': 'verbose', + "delay": False, + }, + 'error_file': { + 'level': 'ERROR', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOGGING_DIR, 'error.log'), + 'when': 'midnight', # Rotate daily + 'interval': 1, # 1 day interval + 'backupCount': 30, # Keep 30 backup copies + 'formatter': 'verbose', + "delay": False, + }, + }, + 'loggers': { + 'django': { + 'handlers': ['debug_file', 'info_file', 'warning_file', 'error_file'], + 'level': os.getenv('DEFAULT_LOG_LEVEL', 'INFO'), + 'propagate': False, + }, + }, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + }, +} + +ASGI_APPLICATION_SHUTDOWN_TIMEOUT = 30 + +CRONJOBS = [ + # ('30 2 * * *', 'chatbot.cron_tasks.chaupal.chaupal_cront_tasks.handle_story_cleanup_cron', + # '>> /tmp/handle_story_cleanup_cron.log 2>&1'), + ('30 3 * * *', 'chatbot.cron_tasks.chaupal.chaupal_cront_tasks.handle_village_ingestion_cron', + '>> /tmp/handle_village_ingestion_cron.log 2>&1'), + ('0 22 * * *', 'chatbot.cron_tasks.translation_cron.handle_non_english_fix_cron', + '>> /tmp/handle_non_english_fix_cron.log 2>&1'), + ('0 12 * * *', 'chatbot.cron_tasks.delhi_shiksha_samvad.story_creation.create_story', + '>> /tmp/delhi_shiksha_samvad_story_creation.log 2>&1'), + ('0 12 * * *', 'chatbot.cron_tasks.shiksha_samvad.story_creation.create_story', + '>> /tmp/shiksha_samvad_story_creation.log 2>&1'), + ('0 */2 * * *', 'chatbot.cron_tasks.telangana_ptm_pilot.school_classification.main', + '>> /tmp/telangana_ptm_pilot_school_classification.log 2>&1'), + ('15 */2 * * *', 'chatbot.cron_tasks.telangana_ptm_pilot.metrics_extraction.extract_metrics', + '>> /tmp/telangana_ptm_pilot_metrics_extraction.log 2>&1'), +] \ No newline at end of file diff --git a/shikshalokam_mohini/urls.py b/shikshalokam_mohini/urls.py new file mode 100644 index 0000000..8468705 --- /dev/null +++ b/shikshalokam_mohini/urls.py @@ -0,0 +1,40 @@ +""" +URL configuration for shikshalokam_mohini project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include, re_path +from django.conf import settings +from django.conf.urls.static import static +from rest_framework.documentation import include_docs_urls +from shikshalokam.views import health_views +from chatbot.views import aws_views + + +admin.site.site_header = 'Mohini Admin Panel' + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('health/', health_views.health_check, name='health_check'), + path('docs/', include_docs_urls(title='API Documentation')), + path('api/shikshalokam/', include('shikshalokam.urls')), + re_path(r'^api/storage/upload-local/(?P.+)$', aws_views.upload_media_local, name='upload_media_local'), + path("", include("chatbot.urls", namespace="chatbot")), +] + +# Serve media files in development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/shikshalokam_mohini/wsgi.py b/shikshalokam_mohini/wsgi.py new file mode 100644 index 0000000..c2cd8b4 --- /dev/null +++ b/shikshalokam_mohini/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for shikshalokam_mohini project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + +application = get_wsgi_application() diff --git a/start_celery_worker.py b/start_celery_worker.py new file mode 100644 index 0000000..84cb128 --- /dev/null +++ b/start_celery_worker.py @@ -0,0 +1,18 @@ +from celery import Celery +from shikshalokam_mohini.celery_config import app +import os + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shikshalokam_mohini.settings') + +REDIS_HOST = os.environ.get('REDIS_HOST', "localhost") +REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) + +app = Celery('shikshalokam_mohini', backend=f'redis://{REDIS_HOST}:{REDIS_PORT}', broker=f'redis://{REDIS_HOST}:{REDIS_PORT}') + +# Load configuration from Django settings. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() +if __name__ == '__main__': + app.start(argv=['-A', 'shikshalokam_mohini.celery_config', 'worker', '--loglevel=info']) diff --git a/startup.sh b/startup.sh new file mode 100755 index 0000000..2c793fe --- /dev/null +++ b/startup.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Configuration variables +FRONTEND_FOLDER="mohini-app-frontend" +FRONTEND_START_CMD="serve -s build -p 3005" + +HOME_FOLDER="/home/ubuntu" # Change this to your home folder (/home/ubuntu) + +# Log file setup +LOG_FILE="$HOME_FOLDER/startup.log" +echo "Starting application components at $(date)" > "$LOG_FILE" + +# Function to log timing information +log_timing() { + local component=$1 + local start_time=$2 + local end_time=$3 + local duration=$((end_time - start_time)) + echo "$component startup took $duration seconds" >> "$LOG_FILE" +} + +# Function to check if a tmux session exists +tmux_session_exists() { + tmux has-session -t "$1" 2>/dev/null +} + +# Function to check and export environment variables +export_env_vars() { + if [ -f ".env" ]; then + export $(cat .env | xargs) + else + echo "Warning: .env file not found in $(pwd)" >> "$LOG_FILE" + fi +} + +# Start timing for Docker containers +docker_start_time=$(date +%s) + +# Build and start Docker containers +echo "Building Docker containers..." >> "$LOG_FILE" +# Stop existing containers if any +if [ -f "docker-compose.yml" ]; then + echo "Stopping existing Docker containers..." >> "$LOG_FILE" + docker compose down >> "$LOG_FILE" 2>&1 + + # Build containers + echo "Building Docker images..." >> "$LOG_FILE" + docker compose build >> "$LOG_FILE" 2>&1 + + # Start containers in detached mode + echo "Starting Docker containers..." >> "$LOG_FILE" + docker compose up -d >> "$LOG_FILE" 2>&1 + + # Wait for containers to be healthy + echo "Waiting for containers to be healthy..." >> "$LOG_FILE" + sleep 10 +else + echo "Warning: docker-compose.yml not found" >> "$LOG_FILE" +fi + +# Log Docker timing +docker_end_time=$(date +%s) +log_timing "Docker containers" $docker_start_time $docker_end_time + +cd $HOME_FOLDER + +# Start timing for frontend +frontend_start_time=$(date +%s) + +# Setup frontend +if [ ! -d "$HOME_FOLDER/$FRONTEND_FOLDER" ]; then + echo "Warning: Frontend folder $FRONTEND_FOLDER not found in $HOME_FOLDER" >> "$LOG_FILE" + exit 1 +fi + +cd "$HOME_FOLDER/$FRONTEND_FOLDER" + +# Install pm2 globally if not already installed +if ! command -v pm2 &> /dev/null; then + echo "Installing PM2 globally..." >> "$LOG_FILE" + npm install -g pm2 +fi + +if ! command -v serve &> /dev/null; then + echo "Installing serve globally..." >> "$LOG_FILE" + npm install -g serve +fi + +# Build and start frontend +echo "Building frontend..." >> "$LOG_FILE" + +# Check if frontend is already running in pm2 +if pm2 list | grep -q "$FRONTEND_FOLDER"; then + echo "Stopping existing frontend PM2 process..." >> "$LOG_FILE" + pm2 delete $FRONTEND_FOLDER +fi + +# Start new frontend process +pm2 start "$FRONTEND_START_CMD" --name $FRONTEND_FOLDER + +# Log frontend timing +frontend_end_time=$(date +%s) +log_timing "Frontend" $frontend_start_time $frontend_end_time + +# Final status check +echo "Final Status Check at $(date):" >> "$LOG_FILE" +echo "Docker Containers:" >> "$LOG_FILE" +docker-compose ps >> "$LOG_FILE" 2>&1 +echo "PM2 Status:" >> "$LOG_FILE" +pm2 list >> "$LOG_FILE" +echo "TMux Sessions:" >> "$LOG_FILE" +tmux ls >> "$LOG_FILE" 2>&1 + +echo "Startup completed at $(date)" >> "$LOG_FILE" \ No newline at end of file diff --git a/system_dependencies.md b/system_dependencies.md new file mode 100644 index 0000000..9aa3ff4 --- /dev/null +++ b/system_dependencies.md @@ -0,0 +1,20 @@ +# System Dependencies + +This project uses media processing libraries that rely on **system-level binaries** +(not installable via `requirements.txt`). These dependencies are required for +PDF previews, video/audio processing, and optional high-fidelity document rendering. +They must be installed on **all machines running Django or Celery workers**. + +## Installation (Ubuntu / Debian) + +```bash +# PDF preview (required) +sudo apt update +sudo apt install -y poppler-utils + +# Video & audio processing (required) +sudo apt install -y ffmpeg + +# Verification +pdfinfo -h +ffmpeg -version diff --git a/test_markdown_extractor.py b/test_markdown_extractor.py new file mode 100644 index 0000000..08e81ba --- /dev/null +++ b/test_markdown_extractor.py @@ -0,0 +1,71 @@ +""" +Quick test script for MarkdownExtractor +Run this to verify the Excel to Markdown conversion works correctly +""" + +import io +import pandas as pd +from chatbot.utils.knowledge_service.extractor.markdown_extractor import MarkdownExtractor + +def test_markdown_extractor(): + """Test the MarkdownExtractor with sample data""" + + # Create sample Excel data + data = { + 'Name': ['John Doe', 'Jane Smith', 'Bob Johnson'], + 'Age': [30, 25, 35], + 'City': ['New York', 'Los Angeles', 'Chicago'], + 'Email': ['john@example.com', 'jane@example.com', 'bob@example.com'] + } + + df = pd.DataFrame(data) + + # Save to bytes + excel_buffer = io.BytesIO() + df.to_excel(excel_buffer, index=False, sheet_name='TestSheet') + excel_bytes = excel_buffer.getvalue() + + # Initialize extractor + extractor = MarkdownExtractor(subdoc_max_chars=5000) + + print("=" * 80) + print("Testing MarkdownExtractor") + print("=" * 80) + + # Test 1: Basic conversion + print("\n1. Testing spreadsheet_to_markdown():") + print("-" * 80) + markdown_output = extractor.spreadsheet_to_markdown(excel_bytes, "test_file.xlsx") + print(markdown_output) + + # Test 2: Limited content extraction + print("\n2. Testing extract_limited_content():") + print("-" * 80) + limited_output = extractor.extract_limited_content(excel_bytes, max_chars=500, filename="test_file.xlsx") + print(limited_output) + print(f"\nLength: {len(limited_output)} characters") + + # Test 3: Comprehensive extraction with URLs + print("\n3. Testing extract_comprehensive_content_for_urls():") + print("-" * 80) + comprehensive_output, urls = extractor.extract_comprehensive_content_for_urls(excel_bytes, "test_file.xlsx") + print(f"Content length: {len(comprehensive_output)} characters") + print(f"URLs extracted: {len(urls)}") + print(f"Sample content:\n{comprehensive_output[:500]}...") + + # Test 4: CSV conversion + print("\n4. Testing CSV conversion:") + print("-" * 80) + csv_buffer = io.BytesIO() + df.to_csv(csv_buffer, index=False) + csv_bytes = csv_buffer.getvalue() + + csv_markdown = extractor.spreadsheet_to_markdown(csv_bytes, "test_file.csv") + print(csv_markdown) + + print("\n" + "=" * 80) + print("All tests completed successfully!") + print("=" * 80) + +if __name__ == "__main__": + test_markdown_extractor() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a1dee9a --- /dev/null +++ b/uv.lock @@ -0,0 +1,6054 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "autobahn" +version = "24.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "cryptography", marker = "python_full_version < '3.11'" }, + { name = "hyperlink", marker = "python_full_version < '3.11'" }, + { name = "setuptools", marker = "python_full_version < '3.11'" }, + { name = "txaio", version = "25.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/f2/8dffb3b709383ba5b47628b0cc4e43e8d12d59eecbddb62cfccac2e7cf6a/autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9", size = 482700, upload-time = "2024-08-02T09:26:48.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ee/a6475f39ef6c6f41c33da6b193e0ffd2c6048f52e1698be6253c59301b72/autobahn-24.4.2-py2.py3-none-any.whl", hash = "sha256:c56a2abe7ac78abbfb778c02892d673a4de58fd004d088cd7ab297db25918e81", size = 666965, upload-time = "2024-08-02T09:26:44.274Z" }, +] + +[[package]] +name = "autobahn" +version = "25.12.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cbor2", marker = "python_full_version >= '3.11'" }, + { name = "cffi", marker = "python_full_version >= '3.11'" }, + { name = "cryptography", marker = "python_full_version >= '3.11'" }, + { name = "hyperlink", marker = "python_full_version >= '3.11'" }, + { name = "msgpack", marker = "python_full_version >= '3.11' and platform_python_implementation == 'CPython'" }, + { name = "py-ubjson", marker = "python_full_version >= '3.11'" }, + { name = "txaio", version = "25.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "u-msgpack-python", marker = "python_full_version >= '3.11' and platform_python_implementation != 'CPython'" }, + { name = "ujson", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/923e4f11dc9d12b9f5a014f36d591c479d623d54dda3bdcbd688cd12f052/autobahn-25.12.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16df879672c60f1f3fe452138c80f0fd221b3cb2ee5a14390c80f33b994104c1", size = 2053413, upload-time = "2025-12-15T11:12:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0d/3d39637a1e32f555ce5fabec4a723a035556ef918b14140faea05e7de902/autobahn-25.12.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffe28048ef96eb0f925f24c2569bd72332e120f4cb31cd6c40dd66718a5f85e", size = 2224850, upload-time = "2025-12-15T11:13:00.089Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/f591e9ec30e3708a3129e151b1fc3bdff8f4dbc84d705f5c42719b859a1a/autobahn-25.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:3ec6a3719a00fd57b044e4694f3d6e9335892f4ef21f045f090495da7385d240", size = 2159161, upload-time = "2025-12-15T11:13:01.504Z" }, + { url = "https://files.pythonhosted.org/packages/64/8d/36452c06cbcad6d04587aeb87dfa987ef94be4a427b9f2155783d166bd97/autobahn-25.12.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:220748f21e91bd4a538d2d3de640cc17ee30b79f1c04a6c3dcdef321d531ee1c", size = 2225453, upload-time = "2025-12-15T11:13:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/47647ff140f2b8ef80aa689451f3f1076404c115d79310f49477143410dc/autobahn-25.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:ba1867aafdbe585d3d4a5abd35238a78ab54ab3de5bd12a21bca20379c9f512b", size = 2157007, upload-time = "2025-12-15T11:13:03.95Z" }, + { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" }, + { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" }, + { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" }, + { url = "https://files.pythonhosted.org/packages/d6/99/b4a3da42471d3ec36e2dca0c1a5368a079fed9f73b159ce3f049c4a4983b/autobahn-25.12.2-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:0c226329ddec154c6f3b491ea3e4713035f0326c96ebfd6b305bf90f27a2fba1", size = 1955357, upload-time = "2025-12-15T11:13:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/89/81/67f19dd7395a9f1123a1f071314f8d1c4879c1869adeb8d99a236e756ac0/autobahn-25.12.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f079393a7626eb448c8accf21151f5f206d02f8e9cee4313d62a5ca30a3aaed", size = 623173, upload-time = "2025-12-15T11:13:14.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/eb/857eab3d25e3b9cc9e7e741d6193808ad91de0befb38cf10658bd339c205/autobahn-25.12.2-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b3a6c7d54a9f0434a435d88b86555510e5d0a84aa87042e292f29f707cab237", size = 2178008, upload-time = "2025-12-15T11:13:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/e0/28/ebd4764fa162455cd9211ac8e1d3733baf87fad5e0e7fc60e8474d172b8f/autobahn-25.12.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0ad4c10c897ad67d31be2ef8547ed2922875d90ddb95553787cc46c271f822de", size = 2157704, upload-time = "2025-12-15T11:13:17.422Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.37" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/0d6ceb88ae2b3638b956190a431e4a8a3697d5769d4bbbede8efcccacaea/boto3-1.42.37.tar.gz", hash = "sha256:d8b6c52c86f3bf04f71a5a53e7fb4d1527592afebffa5170cf3ef7d70966e610", size = 112830, upload-time = "2026-01-28T20:38:43.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/a4/cd334f74498acc6ad42a69c48e8c495f6f721d8abe13f8ef0d4b862fb1c0/boto3-1.42.37-py3-none-any.whl", hash = "sha256:e1e38fd178ffc66cfbe9cb6838b8c460000c3eb741e5f40f57eb730780ef0ed4", size = 140604, upload-time = "2026-01-28T20:38:42.135Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.37" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/94292e7686e64d2ede8dae7102bbb11a1474e407c830de4192f2518e6cff/botocore-1.42.37.tar.gz", hash = "sha256:3ec58eb98b0857f67a2ae6aa3ded51597e7335f7640be654e0e86da4f173b5b2", size = 14914621, upload-time = "2026-01-28T20:38:34.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/30/54042dd3ad8161964f8f47aa418785079bd8d2f17053c40d65bafb9f6eed/botocore-1.42.37-py3-none-any.whl", hash = "sha256:f13bb8b560a10714d96fb7b0c7f17828dfa6e6606a1ead8c01c6ebb8765acbd8", size = 14589390, upload-time = "2026-01-28T20:38:31.306Z" }, +] + +[[package]] +name = "cbor2" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/05/486166d9e998d65d70810e63eeacc8c5f13d167d8797cf2d73a588beb335/cbor2-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2263c0c892194f10012ced24c322d025d9d7b11b41da1c357f3b3fe06676e6b7", size = 69882, upload-time = "2025-12-30T18:43:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d0/ee976eaaf21c211eef651e1a921c109c3c3a3785d98307d74a70d142f341/cbor2-5.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffe4ca079f6f8ed393f5c71a8de22651cb27bd50e74e2bcd6bc9c8f853a732b", size = 260696, upload-time = "2025-12-30T18:43:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/81cabd3aee6cc54b101a5214d5c3e541d275d7c05647c7dfc266c6aacf6f/cbor2-5.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0427bd166230fe4c4b72965c6f2b6273bf29016d97cf08b258fa48db851ea598", size = 252135, upload-time = "2025-12-30T18:43:29.418Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/f38e8c579e7e2d88d446549bce35bde7d845199300bc456b4123d6e6f0af/cbor2-5.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c23a04947c37964d70028ca44ea2a8709f09b8adc0090f9b5710fa957e9bc545", size = 255342, upload-time = "2025-12-30T18:43:30.966Z" }, + { url = "https://files.pythonhosted.org/packages/5d/02/8413f1bd42c8f665fb85374151599cb4957848f0f307d08334a08dee544c/cbor2-5.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:218d5c7d2e8d13c7eded01a1b3fe2a9a1e51a7a843cefb8d38cb4bbbc6ad9bf7", size = 247191, upload-time = "2025-12-30T18:43:32.555Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b8/edeffcad06b83d3661827973a8e6f5d51a9f5842e1ee9d191fdef60388ad/cbor2-5.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ce7d907a25448af7c13415281d739634edfd417228b274309b243ca52ad71f9", size = 69254, upload-time = "2025-12-30T18:43:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1a/dde6537d8d1c2b3157ea6487ea417a5ad0157687d0e9a3ff806bf23c8cb1/cbor2-5.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:628d0ea850aa040921a0e50a08180e7d20cf691432cec3eabc193f643eccfbde", size = 64946, upload-time = "2025-12-30T18:43:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/623435ef9b98e86b6956a41863d39ff4fe4d67983948b5834f55499681dd/cbor2-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ac191640093e6c7fbcb174c006ffec4106c3d8ab788e70272c1c4d933cbe11", size = 69875, upload-time = "2025-12-30T18:43:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/f664201080b2a7d0f57c16c8e9e5922013b92f202e294863ec7e75b7ff7f/cbor2-5.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fddee9103a17d7bed5753f0c7fc6663faa506eb953e50d8287804eccf7b048e6", size = 268316, upload-time = "2025-12-30T18:43:37.161Z" }, + { url = "https://files.pythonhosted.org/packages/d0/e1/072745b4ff01afe9df2cd627f8fc51a1acedb5d3d1253765625d2929db91/cbor2-5.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d2ea26fad620aba5e88d7541be8b10c5034a55db9a23809b7cb49f36803f05b", size = 258874, upload-time = "2025-12-30T18:43:38.878Z" }, + { url = "https://files.pythonhosted.org/packages/a7/10/61c262b886d22b62c56e8aac6d10fa06d0953c997879ab882a31a624952b/cbor2-5.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de68b4b310b072b082d317adc4c5e6910173a6d9455412e6183d72c778d1f54c", size = 261971, upload-time = "2025-12-30T18:43:40.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/42/b7862f5e64364b10ad120ea53e87ec7e891fb268cb99c572348e647cf7e9/cbor2-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:418d2cf0e03e90160fa1474c05a40fe228bbb4a92d1628bdbbd13a48527cb34d", size = 254151, upload-time = "2025-12-30T18:43:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/16/6a/8d3636cf75466c18615e7cfac0d345ee3c030f6c79535faed0c2c02b1839/cbor2-5.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:453200ffa1c285ea46ab5745736a015526d41f22da09cb45594624581d959770", size = 69169, upload-time = "2025-12-30T18:43:43.424Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/79b205bf869558b39a11de70750cb13679b27ba5654a43bed3f2aee7d1b4/cbor2-5.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:f6615412fca973a8b472b3efc4dab01df71cc13f15d8b2c0a1cffac44500f12d", size = 64955, upload-time = "2025-12-30T18:43:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4f/3a16e3e8fd7e5fd86751a4f1aad218a8d19a96e75ec3989c3e95a8fe1d8f/cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9", size = 70270, upload-time = "2025-12-30T18:43:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/38/81/0d0cf0796fe8081492a61c45278f03def21a929535a492dd97c8438f5dbe/cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857", size = 286242, upload-time = "2025-12-30T18:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/fdab6c10190cfb8d639e01f2b168f2406fc847a2a6bc00e7de78c3381d0a/cbor2-5.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cff2a1999e49cd51c23d1b6786a012127fd8f722c5946e82bd7ab3eb307443f3", size = 285412, upload-time = "2025-12-30T18:43:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/31/59/746a8e630996217a3afd523f583fcf7e3d16640d63f9a03f0f4e4f74b5b1/cbor2-5.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c4492160212374973cdc14e46f0565f2462721ef922b40f7ea11e7d613dfb2a", size = 278041, upload-time = "2025-12-30T18:43:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/f3bbeb6dedd45c6e0cddd627ea790dea295eaf82c83f0e2159b733365ebd/cbor2-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:546c7c7c4c6bcdc54a59242e0e82cea8f332b17b4465ae628718fef1fce401ca", size = 278185, upload-time = "2025-12-30T18:43:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/67/e5/9013d6b857ceb6cdb2851ffb5a887f53f2bab934a528c9d6fa73d9989d84/cbor2-5.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:074f0fa7535dd7fdee247c2c99f679d94f3aa058ccb1ccf4126cc72d6d89cbae", size = 69817, upload-time = "2025-12-30T18:43:52.352Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ab/7aa94ba3d44ecbc3a97bdb2fb6a8298063fe2e0b611e539a6fe41e36da20/cbor2-5.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:f95fed480b2a0d843f294d2a1ef4cc0f6a83c7922927f9f558e1f5a8dc54b7ca", size = 64923, upload-time = "2025-12-30T18:43:53.719Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" }, + { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" }, + { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" }, + { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" }, +] + +[[package]] +name = "celery" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "channels" +version = "4.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, +] + +[[package]] +name = "channels-redis" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "channels" }, + { name = "msgpack" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "coreapi" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coreschema" }, + { name = "itypes" }, + { name = "requests" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/f2/5fc0d91a0c40b477b016c0f77d9d419ba25fc47cc11a96c825875ddce5a6/coreapi-2.3.3.tar.gz", hash = "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", size = 18788, upload-time = "2017-10-05T14:04:38.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3a/9dedaad22962770edd334222f2b3c3e7ad5e1c8cab1d6a7992c30329e2e5/coreapi-2.3.3-py2.py3-none-any.whl", hash = "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3", size = 25636, upload-time = "2017-10-05T14:04:40.687Z" }, +] + +[[package]] +name = "coreschema" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/08/1d105a70104e078718421e6c555b8b293259e7fc92f7e9a04869947f198f/coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607", size = 10974, upload-time = "2017-02-08T12:23:49.42Z" } + +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + +[[package]] +name = "daphne" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "autobahn", version = "24.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "autobahn", version = "25.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "twisted", extra = ["tls"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" }, +] + +[[package]] +name = "deepeval" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "grpcio" }, + { name = "jinja2" }, + { name = "nest-asyncio" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "portalocker" }, + { name = "posthog" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyfiglet" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-repeat" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-xdist" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, + { name = "sentry-sdk" }, + { name = "setuptools" }, + { name = "tabulate" }, + { name = "tenacity" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/d8/295a5415fa5432b038e3220f69e95e4aea8a6c29b0d73ec2695ab41d6d39/deepeval-3.8.2.tar.gz", hash = "sha256:9d79d369f6d071abfbd5b24f4697267ac3c7ccdc773e0557981e4592787d8547", size = 590671, upload-time = "2026-01-29T10:14:28.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/6a/469e5a5e96f4043fc5ede61d3142ab4654816d9e0fb1b9dfaf626da430bb/deepeval-3.8.2-py3-none-any.whl", hash = "sha256:d5b260f5f4709df8b48e2adc20e6ba5b23dd0fde0961cbc22ae64741088acc3d", size = 816109, upload-time = "2026-01-29T10:14:26.189Z" }, +] + +[[package]] +name = "diff-match-patch" +version = "20241021" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/ad/32e1777dd57d8e85fa31e3a243af66c538245b8d64b7265bec9a61f2ca33/diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073", size = 39962, upload-time = "2024-10-21T19:41:21.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/bb/2aa9b46a01197398b901e458974c20ed107935c26e44e37ad5b0e5511e44/diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", size = 43252, upload-time = "2024-10-21T19:41:19.914Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "django" +version = "5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/1b/c6da718c65228eb3a7ff7ba6a32d8e80fa840ca9057490504e099e4dd1ef/Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89", size = 10824891, upload-time = "2025-04-02T13:08:06.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361, upload-time = "2025-04-02T13:08:01.465Z" }, +] + +[[package]] +name = "django-admin-rangefilter" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/29/ca800141c33d2df90eb3306c86f5a8c89c11d2d0493e5c503217f57fb0a1/django_admin_rangefilter-0.13.5.tar.gz", hash = "sha256:3134e9e877f59ccad5949cd25cd2db9bf20d170fd8070be584875fdca96d3a14", size = 23712, upload-time = "2025-12-13T07:58:31.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/ad/577f872dcc0107d608085b626103990028655b797eedab884242b61f6ac0/django_admin_rangefilter-0.13.5-py2.py3-none-any.whl", hash = "sha256:7fdcd1ee9007e2b76a5bd35bc017e785bdc7b17a24b8a36aef4ce80741356c47", size = 48383, upload-time = "2025-12-13T07:58:30.028Z" }, +] + +[[package]] +name = "django-celery-results" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, +] + +[[package]] +name = "django-countries" +version = "8.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/2e/ed67f8f460d1de25ee64fca5d7f219680f944fc8ac5a29fbede3574dc3db/django_countries-8.2.0.tar.gz", hash = "sha256:6df3883180599052c7dfa9a8be0601792441cfb248935dc229ad1ac92e9e39e3", size = 2455542, upload-time = "2025-11-24T19:57:08.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/3c/9ebd7ed021b7c519bac954bc88146bc870e7d3c8db2580fa67268464fd2e/django_countries-8.2.0-py3-none-any.whl", hash = "sha256:2b2617bec7c15dc735bdec38ae89f0058e38fddfffdb19a7f6b75ef1e3d5380f", size = 3776079, upload-time = "2025-11-24T19:57:05.576Z" }, +] + +[[package]] +name = "django-crontab" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/bd/a122ba96167f5dfab70a58ca22fa046b7ef1ebad9ff026f7831bd6c2a49c/django-crontab-0.7.1.tar.gz", hash = "sha256:1201810a212460aaaa48eb6a766738740daf42c1a4f6aafecfb1525036929236", size = 7089, upload-time = "2016-03-07T19:35:54.714Z" } + +[[package]] +name = "django-debug-toolbar" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "sqlparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/4d/6acf660500d3d581bfc19460d9605cdf14c275640f35825da1329eaafafa/django_debug_toolbar-6.2.0.tar.gz", hash = "sha256:dc1c174d8fb0ea01435e02d9ceef735cf62daf37c1a6a5692d33b4127327679b", size = 313779, upload-time = "2026-01-20T12:38:25.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/04/e24611299a5ee0d4edfacf935b09cfb7d5d9cb653bd7b7883c3b43a6f90d/django_debug_toolbar-6.2.0-py3-none-any.whl", hash = "sha256:1575461954e6befa720e999dec13fe4f1cc8baf40b6c3ac2aec5f340c0f9c85f", size = 271354, upload-time = "2026-01-20T12:38:23.608Z" }, +] + +[[package]] +name = "django-extensions" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, +] + +[[package]] +name = "django-filter" +version = "25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, +] + +[[package]] +name = "django-import-export" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "diff-match-patch" }, + { name = "django" }, + { name = "tablib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/26/279bc8e6cb2c83d1b5dcdca07e932207c3352af11c6d305d6964a2d03ccc/django_import_export-4.4.0.tar.gz", hash = "sha256:9900e99c89027594941074fb4cd63a5f2964975e239021765c0f066003fcd412", size = 2237714, upload-time = "2026-01-10T20:57:35.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/e0/f4aa6d2374cc6b53b23f36bd0d5814e1db2769b25931b9908723fa295bb0/django_import_export-4.4.0-py3-none-any.whl", hash = "sha256:2d9b234c0f024d3377167f4d9c5a506e095c5bad98e06d30700e1d0752829e3d", size = 157449, upload-time = "2026-01-10T20:57:33.141Z" }, +] + +[[package]] +name = "django-jazzmin" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/96/21b6255e90d92a3eb4e93bea9376635d54258e0353ebb913a55e40ae9254/django_jazzmin-3.0.1.tar.gz", hash = "sha256:67ae148bade41267a09ca8e4352ddefa6121795ebbac238bb9a6564ff841eb1b", size = 2053550, upload-time = "2024-10-08T17:40:59.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/5b/2f8c4b168e6c41bf1e4b14d787deb23d80f618f0693db913bbe208a4a907/django_jazzmin-3.0.1-py3-none-any.whl", hash = "sha256:12a0a4c1d4fd09c2eef22acf6a1f03112b515ba695c59faa8ea80efc81c1f21b", size = 2125957, upload-time = "2024-10-08T17:40:57.359Z" }, +] + +[[package]] +name = "django-querycount" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fe/ca8acf0ec02d26736500ce0e8b4979b0bb169340a23d34420b06ae5df982/django-querycount-0.8.3.tar.gz", hash = "sha256:0782484e8a1bd29498fa0195a67106e47cdcc98fafe80cebb1991964077cb694", size = 6739, upload-time = "2023-01-05T22:01:20.252Z" } + +[[package]] +name = "django-redis" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" }, +] + +[[package]] +name = "django-s3-storage" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/2b/e2b4af54fbdd4e845a5098044ed4e5c60b28129ff5ff289fe57f08f272f6/django-s3-storage-0.15.0.tar.gz", hash = "sha256:2f81ada72de54f7ba092a079aaf1e03f32e3bd9901ddcf62847ebe7de4fc554c", size = 12945, upload-time = "2023-11-04T15:58:52.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/8f/5a27598f3795c79db4bd3846722981079935eaa594194d417ace9764dd0b/django_s3_storage-0.15.0-py3-none-any.whl", hash = "sha256:d6805d09ce6ffb79c25b97d22dacdad5084bbfa67c361c5f4deaf4bce50ac635", size = 9891, upload-time = "2023-11-04T15:58:51.308Z" }, +] + +[[package]] +name = "django-simple-history" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/11/410049f1454b99a78f719d3403fc89437c2a38ee092e939d5ab8d4846738/django_simple_history-3.11.0.tar.gz", hash = "sha256:2c587479cf2c3071e9aa555d0d11b73676994db4910770958f57659ade2deffe", size = 234862, upload-time = "2025-12-11T13:50:55.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c2/e9854a3438cfc80891ab4d3826b7c61a0fe5ba3a4da89104a8f5c9afb5df/django_simple_history-3.11.0-py3-none-any.whl", hash = "sha256:f3c298db49e418ffce7fb709a5e83108452ea2179ec5c4b9232484c25427192a", size = 81868, upload-time = "2025-12-11T13:50:53.71Z" }, +] + +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[[package]] +name = "django-tailwind" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "django", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/75/f244c147c6fe04a565f36721cf04d8b36c8bae8bdd17ec1656603d71ce05/django_tailwind-4.2.0.tar.gz", hash = "sha256:60bae45a9981bdf3ebaaa4a8e588533327c90e5857a64f57c26a39b7f234ac0e", size = 11734, upload-time = "2025-07-08T20:56:45.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/f5/f91497da51098d7a25ee7409a49d24c880e3af3664da0872dbe238a7174b/django_tailwind-4.2.0-py3-none-any.whl", hash = "sha256:ff40ccb263faefac555fbaa340d6014f2b07fee6be2c8d5659f2cd63dbce02e9", size = 19225, upload-time = "2025-07-08T20:56:43.908Z" }, +] + +[[package]] +name = "django-tailwind" +version = "4.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "django", marker = "python_full_version >= '3.11'" }, + { name = "pytailwindcss", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/21/86fda52a8d0f8d2f31d32982ee4d9cc4f29c868dbeb52412e430d083d126/django_tailwind-4.4.2.tar.gz", hash = "sha256:b3a3eb2d22cbb8c17565898fb68ccedcf806542b04fe4107bdfec7035d582819", size = 13965, upload-time = "2025-12-05T18:23:41.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ed/85113d22ab4268600542152bc4b5512abb1204552b90e04848ebc496aa5c/django_tailwind-4.4.2-py3-none-any.whl", hash = "sha256:0e4a2836cb36e8952700457d049fadb8743583017cef80fa3a374f8597c289f4", size = 23358, upload-time = "2025-12-05T18:23:39.462Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/b2/731a6696e37cd20eed353f69a09f37a984a43c9713764ee3f7ad5f57f7f9/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a", size = 516760, upload-time = "2025-10-19T22:25:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/c5/79/c73c47be2a3b8734d16e628982653517f80bbe0570e27185d91af6096507/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00", size = 264748, upload-time = "2025-10-19T22:41:52.873Z" }, + { url = "https://files.pythonhosted.org/packages/24/c5/84c1eea05977c8ba5173555b0133e3558dc628bcf868d6bf1689ff14aedc/fastuuid-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470", size = 254537, upload-time = "2025-10-19T22:33:55.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/4e362367b7fa17dbed646922f216b9921efb486e7abe02147e4b917359f8/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d", size = 278994, upload-time = "2025-10-19T22:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/3985be633b5a428e9eaec4287ed4b873b7c4c53a9639a8b416637223c4cd/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8", size = 280003, upload-time = "2025-10-19T22:23:45.415Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/6ef192a6df34e2266d5c9deb39cd3eea986df650cbcfeaf171aa52a059c3/fastuuid-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219", size = 303583, upload-time = "2025-10-19T22:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/9d/11/8a2ea753c68d4fece29d5d7c6f3f903948cc6e82d1823bc9f7f7c0355db3/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6", size = 460955, upload-time = "2025-10-19T22:36:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/42/7a32c93b6ce12642d9a152ee4753a078f372c9ebb893bc489d838dd4afd5/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe", size = 480763, upload-time = "2025-10-19T22:24:28.451Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e9/a5f6f686b46e3ed4ed3b93770111c233baac87dd6586a411b4988018ef1d/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d", size = 452613, upload-time = "2025-10-19T22:25:06.827Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c9/18abc73c9c5b7fc0e476c1733b678783b2e8a35b0be9babd423571d44e98/fastuuid-0.14.0-cp310-cp310-win32.whl", hash = "sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a", size = 155045, upload-time = "2025-10-19T22:28:32.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8a/d9e33f4eb4d4f6d9f2c5c7d7e96b5cdbb535c93f3b1ad6acce97ee9d4bf8/fastuuid-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4", size = 156122, upload-time = "2025-10-19T22:23:15.59Z" }, + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, +] + +[[package]] +name = "gevent" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/92/fd1f4016b92591fe7ccf44d01883a3337178a6e9ea4c04e1ddce7a2e3db1/gevent-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:079dca3fe5e048714e93ab5ce7cbf2ec31709c860979feb117abbbf2b8ae6ad0", size = 2180478, upload-time = "2026-04-08T21:54:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/ac8a6561b9efd141dfbb2d0ae1d2fd3255f9e9ccfeceb64a6917e2ef6625/gevent-26.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:df208cd53c7382a4cc8470d39a92fc73b3cf2f0f3379d6c88bb556823a26ccb7", size = 2211145, upload-time = "2026-04-08T21:59:59.318Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/2cd44b3a2f60903cae39602045344b434e8621f7145470f0f00e5be8a44a/gevent-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ccaa0f002cfd69c03621ac05243c1a00ed77cee97b363d0108b0e36663e4ca33", size = 1694090, upload-time = "2026-04-08T23:40:50.101Z" }, + { url = "https://files.pythonhosted.org/packages/12/0e/330c4788860520850b7f4c6f84dd8591df5172cfd3f2796c046704ee879e/gevent-26.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:201323a5fb9a0646a0c7b384395ca55d60ee83200677919229df0648c4b78e6c", size = 1767278, upload-time = "2026-04-08T22:23:16.491Z" }, + { url = "https://files.pythonhosted.org/packages/cf/27/717593d7cce74a2fd6bee0713793518e0398132303d5267f02dd587c5945/gevent-26.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:82d68a60a4207826db295b4e80a204c9d392ce78ccc15679195faeb9e29d8388", size = 1861609, upload-time = "2026-04-08T22:27:09.302Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/ce6d4d554d9afc354b46b78eccb032f6add4d27c3eadaa0201ee103fa831/gevent-26.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:35b037b415ed38369717800250fe5974249525953b46026bef9def20f946dfb0", size = 1803675, upload-time = "2026-04-08T22:34:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/909166fff7d2ab9523e93bbd56e863df79a856b2857350218be83aef119d/gevent-26.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6d973735d2067607a32cd182893978755eee829a0dc268087592d3b715e63fad", size = 2118034, upload-time = "2026-04-08T21:54:13.293Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/ab68e1cc09fd6dd7adb9e1c54a47c6328df20aa012ed75526a3244f2ad05/gevent-26.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fbd3ff28a7babbfee750684c4f46ba6eedb3bce69365dd146726986b79fa6c1", size = 1777768, upload-time = "2026-04-08T22:26:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d3/b75568e7206ea4b89a7e21750381aa4a6f9afcced41d5a80a72b5fcc6b87/gevent-26.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0a03650ca60c4c5774cbe21333905b95f2f5abd98ea5a3dbf28d93f2a7a5a84", size = 2144355, upload-time = "2026-04-08T22:00:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/5f795143351bfbecd05467deec48ea75416bb90eb7a6dd042c7d7e5ec594/gevent-26.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d00d8c4ca1afab90e478b79679dd53787082c6da8d2f4fdc7667a4440d1e1a7a", size = 1676684, upload-time = "2026-04-08T23:28:04.124Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/131d3874f50974b355c90a061a12d3fe2292cde0f875a1fa3d8b224f1251/gevent-26.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:318a0a73f664113e8d86d0cb0e328e7650e2d7d9c2e045418ab6fb1285831ad3", size = 2928699, upload-time = "2026-04-08T21:25:36.215Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/199e59b303adaff7f7365def9ab569c7ecd863363c974548bce3ddc2c89d/gevent-26.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ce7aa033a3f68beb6732d1450a80c1af29e63e0c2d01abad7918cf2507f72fa6", size = 1783821, upload-time = "2026-04-08T22:23:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/b8249c9bd3f386191311c3a9bec4068e192a3f9df2fad92a71a15265ba15/gevent-26.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:a1b897c952baefd72232efaeb3bdb1ca2fa7ae94cbfe68ac21201b03e843190a", size = 1879424, upload-time = "2026-04-08T22:27:10.561Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/59216985c1f2c11f2f28bbc88e583588ad44cdde823c530ad4e307be6612/gevent-26.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:7eef2ea508ce41795e20587a5fc868ae4919543097c81a40fbdfd65bc479f54f", size = 1830575, upload-time = "2026-04-08T22:34:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/2d67d2b0aa0ca9d7bb7fe73c3bbb97b3695cb15c338a6ea7734f58da9add/gevent-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7e12fdd28cc9f39a463d8df5172d698c64a8ed385a21d98e7092fd8308a139a", size = 2113898, upload-time = "2026-04-08T21:54:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/457d58d9b3e7da17c8456d841c37a32af8d231a1d71237ad201b19129317/gevent-26.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d48e3ee13d7678c24c22f19d441ad6bc220a79f23662d03ff36fae0d62efdb59", size = 1795890, upload-time = "2026-04-08T22:26:53.252Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cc/cbe78f2626643b20275aaa41cd2cc45ba75056e3665bde36bc190af3cae0/gevent-26.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c58c8e034f94329be4dc0979fba3301005a433dbab42cea0b2c33fd736946872", size = 2139791, upload-time = "2026-04-08T22:00:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/7875e08b06a95f4577b71708ec470d029fadf873a66eb813a2861d79dfb5/gevent-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c737e6ac6ce1398df0e3f41c58d982e397c993cbe73ac05b7edbe39e128c9cb", size = 1680530, upload-time = "2026-04-08T23:15:38.714Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/51809d98bb00846d7756a0b82625024f9302145f3d024846b43f05efeddb/gevent-26.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1fe581d41c63cd1d8b12c69561ce53a48ad0d8763b254740d7bfea997335a38c", size = 2951507, upload-time = "2026-04-08T21:25:25.809Z" }, + { url = "https://files.pythonhosted.org/packages/d6/86/89325a62a4e8cc1934e155b383b66491ed21d1e774b13d5054d51fa0ac81/gevent-26.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c503b0c0a681e795255a13e5bb4e41615c3b020c1db93b8dfa04cfeb8f19d5a9", size = 1786029, upload-time = "2026-04-08T22:23:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/04d112844aa992da583cbd280f17a4ba097da338dab347efd0aa5e235645/gevent-26.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:684256c29e3e5d4d0c4d06b772d00574d0dc859dfbb2fd13d318c512b16e1f89", size = 1881326, upload-time = "2026-04-08T22:27:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/a1/33/71900c5ba442f5df89456b6d9fdaa43da2ae7cdd937d8c5667b49323ceb4/gevent-26.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:73eafd06b158d511f1ec6e5902a45e0ae3b48e745f35e9df97d25f809f537d88", size = 1833123, upload-time = "2026-04-08T22:34:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/d0/af/7df19c92e56842921f34787e1168c7afc52a23b0d1253bba99344809a935/gevent-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a18e543c830a1c07a2efeb33786a57ccac360af70cb42bbaf5a6f5f7ca49300", size = 2114330, upload-time = "2026-04-08T21:54:16.547Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0e/202694960f8d4dda68fd2a73bbcb8251e2d5308339924310ff1fff31bf7c/gevent-26.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:74f1e3a460c43aefcb4ff9ef91aac15abc0b42e5233771e1956574d14ba9cac6", size = 1798427, upload-time = "2026-04-08T22:26:54.462Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/2d056b2a4e3ef1f65f94002725572d1e99163ff79231dbb68ad529e7cb9d/gevent-26.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:954258873ae0bcc97fb41e48db25284fb73454bfefe27db8ceb89225da5502fb", size = 2140100, upload-time = "2026-04-08T22:00:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/1a7f64aa2476c2b44abaecca919a6561bda85234f99fc7ac3c66bcb93050/gevent-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a9a64c064457c1afaf93ee2815fe0f38be6ecbb92806a6a712f12afc3e26cf5", size = 1680206, upload-time = "2026-04-08T23:01:56.636Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/64638a941988f09aa1816e2674eb1efb215b6fa64a97edef6e25177b0845/gevent-26.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7ab0f183a6fd2369eef619832eef14f1f2f69c605163c3f2dc41deb799af4a71", size = 2967206, upload-time = "2026-04-08T21:25:44.73Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/a86be65a51d3ebb92c82a70adc9c5c32b1a9d9579120d0be1db7cf534ce0/gevent-26.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7e5906860e632bf965e1966c57e6bfc19dcb79dc262f04fdb0a9d7c12147bf69", size = 1792916, upload-time = "2026-04-08T22:23:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/40/92/18fdb4b28f20129395f1c041773adee99e7fc2bcfff216df93bfb80787d5/gevent-26.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:297a361071dc6708115d4544859321e93b02a6cd5823ba02c0a909530a519d45", size = 1886617, upload-time = "2026-04-08T22:27:13.716Z" }, + { url = "https://files.pythonhosted.org/packages/af/c9/d02222ecf79d10c8a0c2755661485395b58c4bfffaafd88bcc230ce392de/gevent-26.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:7e74f59e5c9011afa2a9cb7106bb9a59f2a1f74c3d7b272c1b852eb0bc0b8f90", size = 1837660, upload-time = "2026-04-08T22:34:40.823Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/9376d125fa4f7b0f269925d0d622eda0ff8f8dfc8d0c097a096c511fc738/gevent-26.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:45d6010a6a981f5a2b3411c4e38fbe305a1b46e4b12db3b4914775927dea7ba4", size = 2119342, upload-time = "2026-04-08T21:54:17.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/1fe2817daca8e97c365fd739dd4057f71cce26ef600fb8465deb8060c83c/gevent-26.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc38137ba2f43794c488615aafa2eefd0cc142f484a8274d4c827ed7a031a1e2", size = 1805672, upload-time = "2026-04-08T22:26:55.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cc/ccbcbd56e7e85482291fbb90a317f5febf630ec4174a91506f4167ba0912/gevent-26.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:29a225d2d4da37e20c7a246754a64442d0e43e4534b8cc764f89530bb22a4237", size = 2145594, upload-time = "2026-04-08T22:00:05.275Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b9/7dd37b6001d16f692b1bfb6e68cad642beb38b34a753c29bbff312f46e4b/gevent-26.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1c08bc9bb6bd79732a26710a99588b5e9b67b668e165dd609704b876f41baab", size = 1703189, upload-time = "2026-04-08T22:48:31.713Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.197.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/09/081d66357118bd260f8f182cb1b2dd5bd32ca88e3714d7c93896cab946fc/google_api_python_client-2.197.0.tar.gz", hash = "sha256:32e03977eda4a66eafc6ae58dc9ec46426b6025636d5ef019c5703013eddd4e5", size = 14707398, upload-time = "2026-05-28T20:23:12.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e5/e9cc221fd75230974d4ef45eb72d2261feca3c110d5554215d516bfe6534/google_api_python_client-2.197.0-py3-none-any.whl", hash = "sha256:0f8b89aa75768161dd4f5092d6bcb386c13236b32e0d9a938c02f71342094d14", size = 15287302, upload-time = "2026-05-28T20:23:09.683Z" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-speech" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/01/0bfe56e1f935285ac21908ddfb5574b09bf5829ad3441649e1403f9f1b34/google_cloud_speech-2.36.0.tar.gz", hash = "sha256:3a445a033cc7772f7d073c03142a7e80048415db42981372c6b81edc76a1e27a", size = 401922, upload-time = "2026-01-15T13:04:52.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/0e/29e5d7bfe636e7b7b2647d6f4d50ca07b84149ba9adba86c6be219ac8480/google_cloud_speech-2.36.0-py3-none-any.whl", hash = "sha256:bdd0047fe2961d42307bdb7393fe5c7c1491290b2e6d66bfc6e2b7bcdfb8794e", size = 342111, upload-time = "2026-01-15T13:02:59.482Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/09/8953e2993e604c8882fd441b5b2de624a2dfe7e6144c6166d7b477509596/google_cloud_storage-3.11.0.tar.gz", hash = "sha256:498bf37c999028f69a245f586b5e50d89f59df1fafc0e3a93783ac56be2a456b", size = 17335639, upload-time = "2026-06-03T16:14:04.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/7e/ee0dd1a67ac75d29d0c438969d85d4fadbc4bcab47b0a8ccfa7eb22f643c/google_cloud_storage-3.11.0-py3-none-any.whl", hash = "sha256:cfcc33aa6b899ec9dd1771286f8e79fbed5c35c1c174718071b079aa827f37c2", size = 339654, upload-time = "2026-06-03T16:12:46.052Z" }, +] + +[[package]] +name = "google-cloud-texttospeech" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/2b/cf31829499db007107d6a6777bb92dec8b9cc369aa7e579664c0c530c521/google_cloud_texttospeech-2.34.0.tar.gz", hash = "sha256:65837c3bc728f37290009dbef892df87eae45ad80905559096d1107822074f62", size = 192973, upload-time = "2026-01-15T13:05:09.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/0a/292131ec789045f1c646f3ced623b457dc5f52ce8c681f66f2def5724a76/google_cloud_texttospeech-2.34.0-py3-none-any.whl", hash = "sha256:0929c06e91a5b8309db7b93d79e7e1e3e7751d21c5da72fa71843c041fc29ecb", size = 196009, upload-time = "2026-01-15T13:02:44.617Z" }, +] + +[[package]] +name = "google-cloud-translate" +version = "3.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/76/4acb671860b86a18aedd94413577e8e66813daadf00cbcc92fd64e022cf0/google_cloud_translate-3.24.0.tar.gz", hash = "sha256:2f3b8b90f8cdaf63a435d18e63b21c3650de31fc4f858623f2d0d69be0cd3e9a", size = 274482, upload-time = "2026-01-15T13:05:11.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/b5/42e9fbc5086ff0d270118f46b5822fc4bb09c30510d5b615b71fbb5bfd98/google_cloud_translate-3.24.0-py3-none-any.whl", hash = "sha256:a4000f01ab51ff790913c3f40425e118e2632e7cd1589ae0401d19e6b355aedb", size = 209351, upload-time = "2026-01-15T13:03:06.868Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, + { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/bc/e30e1e3d5e8860b0e0ce4d2b16b2681b77fd13542fc0d72f7e3c22d16eff/greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6", size = 284315, upload-time = "2026-04-08T17:02:52.322Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cc/e023ae1967d2a26737387cac083e99e47f65f58868bd155c4c80c01ec4e0/greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82", size = 601916, upload-time = "2026-04-08T16:24:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/5be1677954b6d8810b33abe94e3eb88726311c58fa777dc97e390f7caf5a/greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31", size = 616399, upload-time = "2026-04-08T16:30:54.536Z" }, + { url = "https://files.pythonhosted.org/packages/82/0a/3a4af092b09ea02bcda30f33fd7db397619132fe52c6ece24b9363130d34/greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508", size = 621077, upload-time = "2026-04-08T16:40:34.946Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/2d58d5ea515704f83e34699128c9072a34bea27d2b6a556e102105fe62a5/greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398", size = 611978, upload-time = "2026-04-08T15:56:31.335Z" }, + { url = "https://files.pythonhosted.org/packages/8c/39/3786520a7d5e33ee87b3da2531f589a3882abf686a42a3773183a41ef010/greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb", size = 416893, upload-time = "2026-04-08T16:43:02.392Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/6525049b6c179d8a923256304d8387b8bdd4acab1acf0407852463c6d514/greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b", size = 1571957, upload-time = "2026-04-08T16:26:17.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/bbfb798b05fec736a0d24dc23e81b45bcee87f45a83cfb39db031853bddc/greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf", size = 1637223, upload-time = "2026-04-08T15:57:27.556Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7d/981fe0e7c07bd9d5e7eb18decb8590a11e3955878291f7a7de2e9c668eb7/greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab", size = 237902, upload-time = "2026-04-08T17:03:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, + { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, + { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, + { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, + { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "shellingham" }, + { name = "tqdm" }, + { name = "typer-slim" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e9/2658cb9bc4c72a67b7f87650e827266139befaf499095883d30dabc4d49f/huggingface_hub-1.3.5.tar.gz", hash = "sha256:8045aca8ddab35d937138f3c386c6d43a275f53437c5c64cdc9aa8408653b4ed", size = 627456, upload-time = "2026-01-29T10:34:19.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/84/a579b95c46fe8e319f89dc700c087596f665141575f4dcf136aaa97d856f/huggingface_hub-1.3.5-py3-none-any.whl", hash = "sha256:fe332d7f86a8af874768452295c22cd3f37730fb2463cf6cc3295e26036f8ef9", size = 536675, upload-time = "2026-01-29T10:34:17.713Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "import-export" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/cd/1a8c0719058ad9869758a3b4b2417519241c2c9f57ebc074730dbf664adf/import-export-0.3.1.tar.gz", hash = "sha256:1e47906ef2cb35bedaf5082e37ead4f332f35757005a7eff85bf9aef85ea6a7b", size = 5060, upload-time = "2022-11-30T22:39:51.629Z" } + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "incremental" +version = "24.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "instructor" +version = "1.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "diskcache" }, + { name = "docstring-parser" }, + { name = "jinja2" }, + { name = "jiter" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/ef/986d059424db204ed57b29d8c07fda35de2a2c72dee8ea7994bc90a6f767/instructor-1.14.5.tar.gz", hash = "sha256:fcb6432867f2fe4a5986e8bf389dcc64ed2ad4039a12a2dff85464e51c2f171a", size = 69950754, upload-time = "2026-01-29T14:18:50.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/04/e442e1356c97b03a6d30d2b462f7c0bdfbf207e75f6833815fd1225a75b4/instructor-1.14.5-py3-none-any.whl", hash = "sha256:2a5a31222b008c0989be1cc001e33a237f49506e80ac5833f6d36d7690bae7b1", size = 177445, upload-time = "2026-01-29T14:18:53.641Z" }, +] + +[[package]] +name = "itypes" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/53/764524b3907d0af00523f8794daca181c08ca7cb32ceee25a0754d5e63a5/itypes-1.2.0.tar.gz", hash = "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1", size = 4355, upload-time = "2020-04-19T21:50:13.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/bb/3bd99c7cd34d4a123b2903e16da364f6d2078b1c3a3530a8ad105c668104/itypes-1.2.0-py2.py3-none-any.whl", hash = "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", size = 4756, upload-time = "2020-04-19T21:50:11.704Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/68/0357982493a7b20925aece061f7fb7a2678e3b232f8d73a6edb7e5304443/jiter-0.11.1.tar.gz", hash = "sha256:849dcfc76481c0ea0099391235b7ca97d7279e0fa4c86005457ac7c88e8b76dc", size = 168385, upload-time = "2025-10-17T11:31:15.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/10/d099def5716452c8d5ffa527405373a44ddaf8e3c9d4f6de1e1344cffd90/jiter-0.11.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ed58841a491bbbf3f7c55a6b68fff568439ab73b2cce27ace0e169057b5851df", size = 310078, upload-time = "2025-10-17T11:28:36.186Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/b81d010b0031ffa96dfb590628562ac5f513ce56aa2ab451d29fb3fedeb9/jiter-0.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:499beb9b2d7e51d61095a8de39ebcab1d1778f2a74085f8305a969f6cee9f3e4", size = 317138, upload-time = "2025-10-17T11:28:38.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/12/31ea12af9d79671cc7bd893bf0ccaf3467624c0fc7146a0cbfe7b549bcfa/jiter-0.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b87b2821795e28cc990939b68ce7a038edea680a24910bd68a79d54ff3f03c02", size = 348964, upload-time = "2025-10-17T11:28:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/95cb6dc5ff962410667a29708c7a6c0691cc3c4866a0bfa79d085b56ebd6/jiter-0.11.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83f6fa494d8bba14ab100417c80e70d32d737e805cb85be2052d771c76fcd1f8", size = 363289, upload-time = "2025-10-17T11:28:41.49Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3e/37006ad5843a0bc3a3ec3a6c44710d7a154113befaf5f26d2fe190668b63/jiter-0.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fbc6aea1daa2ec6f5ed465f0c5e7b0607175062ceebbea5ca70dd5ddab58083", size = 487243, upload-time = "2025-10-17T11:28:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/80/5c/d38c8c801a322a0c0de47b9618c16fd766366f087ce37c4e55ae8e3c8b03/jiter-0.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:302288e2edc43174bb2db838e94688d724f9aad26c5fb9a74f7a5fb427452a6a", size = 376139, upload-time = "2025-10-17T11:28:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cd/442ad2389a5570b0ee673f93e14bbe8cdecd3e08a9ba7756081d84065e4c/jiter-0.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85db563fe3b367bb568af5d29dea4d4066d923b8e01f3417d25ebecd958de815", size = 359279, upload-time = "2025-10-17T11:28:46.152Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/8f5810d0e7d00bc395889085dbc1ccc36d454b56f28b2a5359dfd1bab48d/jiter-0.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f1c1ba2b6b22f775444ef53bc2d5778396d3520abc7b2e1da8eb0c27cb3ffb10", size = 384911, upload-time = "2025-10-17T11:28:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bd/8c069ceb0bafcf6b4aa5de0c27f02faf50468df39564a02e1a12389ad6c2/jiter-0.11.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:523be464b14f8fd0cc78da6964b87b5515a056427a2579f9085ce30197a1b54a", size = 517879, upload-time = "2025-10-17T11:28:49.902Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3c/9163efcf762f79f47433078b4f0a1bddc56096082c02c6cae2f47f07f56f/jiter-0.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25b99b3f04cd2a38fefb22e822e35eb203a2cd37d680dbbc0c0ba966918af336", size = 508739, upload-time = "2025-10-17T11:28:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/44/07/50690f257935845d3114b95b5dd03749eeaab5e395cbb522f9e957da4551/jiter-0.11.1-cp310-cp310-win32.whl", hash = "sha256:47a79e90545a596bb9104109777894033347b11180d4751a216afef14072dbe7", size = 203948, upload-time = "2025-10-17T11:28:54.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/5964a944bf2e98ffd566153fdc2a6a368fcb11b58cc46832ca8c75808dba/jiter-0.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:cace75621ae9bd66878bf69fbd4dfc1a28ef8661e0c2d0eb72d3d6f1268eddf5", size = 207522, upload-time = "2025-10-17T11:28:56.79Z" }, + { url = "https://files.pythonhosted.org/packages/8b/34/c9e6cfe876f9a24f43ed53fe29f052ce02bd8d5f5a387dbf46ad3764bef0/jiter-0.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b0088ff3c374ce8ce0168523ec8e97122ebb788f950cf7bb8e39c7dc6a876a2", size = 310160, upload-time = "2025-10-17T11:28:59.174Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/b06ec8181d7165858faf2ac5287c54fe52b2287760b7fe1ba9c06890255f/jiter-0.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74433962dd3c3090655e02e461267095d6c84f0741c7827de11022ef8d7ff661", size = 316573, upload-time = "2025-10-17T11:29:00.905Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/3179d93090f2ed0c6b091a9c210f266d2d020d82c96f753260af536371d0/jiter-0.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d98030e345e6546df2cc2c08309c502466c66c4747b043f1a0d415fada862b8", size = 348998, upload-time = "2025-10-17T11:29:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/63db2c8eabda7a9cad65a2e808ca34aaa8689d98d498f5a2357d7a2e2cec/jiter-0.11.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d6db0b2e788db46bec2cf729a88b6dd36959af2abd9fa2312dfba5acdd96dcb", size = 363413, upload-time = "2025-10-17T11:29:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/ff/3e6b3170c5053053c7baddb8d44e2bf11ff44cd71024a280a8438ae6ba32/jiter-0.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55678fbbda261eafe7289165dd2ddd0e922df5f9a1ae46d7c79a5a15242bd7d1", size = 487144, upload-time = "2025-10-17T11:29:05.37Z" }, + { url = "https://files.pythonhosted.org/packages/b0/50/b63fcadf699893269b997f4c2e88400bc68f085c6db698c6e5e69d63b2c1/jiter-0.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a6b74fae8e40497653b52ce6ca0f1b13457af769af6fb9c1113efc8b5b4d9be", size = 376215, upload-time = "2025-10-17T11:29:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/39/8c/57a8a89401134167e87e73471b9cca321cf651c1fd78c45f3a0f16932213/jiter-0.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a55a453f8b035eb4f7852a79a065d616b7971a17f5e37a9296b4b38d3b619e4", size = 359163, upload-time = "2025-10-17T11:29:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/30b0cdbffbb6f753e25339d3dbbe26890c9ef119928314578201c758aace/jiter-0.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2638148099022e6bdb3f42904289cd2e403609356fb06eb36ddec2d50958bc29", size = 385344, upload-time = "2025-10-17T11:29:10.69Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d5/31dae27c1cc9410ad52bb514f11bfa4f286f7d6ef9d287b98b8831e156ec/jiter-0.11.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:252490567a5d990986f83b95a5f1ca1bf205ebd27b3e9e93bb7c2592380e29b9", size = 517972, upload-time = "2025-10-17T11:29:12.174Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/5905a7a3aceab80de13ab226fd690471a5e1ee7e554dc1015e55f1a6b896/jiter-0.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d431d52b0ca2436eea6195f0f48528202100c7deda354cb7aac0a302167594d5", size = 508408, upload-time = "2025-10-17T11:29:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/91/12/1c49b97aa49077e136e8591cef7162f0d3e2860ae457a2d35868fd1521ef/jiter-0.11.1-cp311-cp311-win32.whl", hash = "sha256:db6f41e40f8bae20c86cb574b48c4fd9f28ee1c71cb044e9ec12e78ab757ba3a", size = 203937, upload-time = "2025-10-17T11:29:14.894Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9d/2255f7c17134ee9892c7e013c32d5bcf4bce64eb115402c9fe5e727a67eb/jiter-0.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0cc407b8e6cdff01b06bb80f61225c8b090c3df108ebade5e0c3c10993735b19", size = 207589, upload-time = "2025-10-17T11:29:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/3c/28/6307fc8f95afef84cae6caf5429fee58ef16a582c2ff4db317ceb3e352fa/jiter-0.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:fe04ea475392a91896d1936367854d346724a1045a247e5d1c196410473b8869", size = 188391, upload-time = "2025-10-17T11:29:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/318e8af2c904a9d29af91f78c1e18f0592e189bbdb8a462902d31fe20682/jiter-0.11.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c92148eec91052538ce6823dfca9525f5cfc8b622d7f07e9891a280f61b8c96c", size = 305655, upload-time = "2025-10-17T11:29:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/f7/29/6c7de6b5d6e511d9e736312c0c9bfcee8f9b6bef68182a08b1d78767e627/jiter-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd4da91b5415f183a6be8f7158d127bdd9e6a3174138293c0d48d6ea2f2009d", size = 315645, upload-time = "2025-10-17T11:29:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5f/ef9e5675511ee0eb7f98dd8c90509e1f7743dbb7c350071acae87b0145f3/jiter-0.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e3ac25c00b9275684d47aa42febaa90a9958e19fd1726c4ecf755fbe5e553b", size = 348003, upload-time = "2025-10-17T11:29:22.712Z" }, + { url = "https://files.pythonhosted.org/packages/56/1b/abe8c4021010b0a320d3c62682769b700fb66f92c6db02d1a1381b3db025/jiter-0.11.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d7305c0a841858f866cd459cd9303f73883fb5e097257f3d4a3920722c69d4", size = 365122, upload-time = "2025-10-17T11:29:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2d/4a18013939a4f24432f805fbd5a19893e64650b933edb057cd405275a538/jiter-0.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e86fa10e117dce22c547f31dd6d2a9a222707d54853d8de4e9a2279d2c97f239", size = 488360, upload-time = "2025-10-17T11:29:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/38124f5d02ac4131f0dfbcfd1a19a0fac305fa2c005bc4f9f0736914a1a4/jiter-0.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae5ef1d48aec7e01ee8420155d901bb1d192998fa811a65ebb82c043ee186711", size = 376884, upload-time = "2025-10-17T11:29:27.056Z" }, + { url = "https://files.pythonhosted.org/packages/7b/43/59fdc2f6267959b71dd23ce0bd8d4aeaf55566aa435a5d00f53d53c7eb24/jiter-0.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb68e7bf65c990531ad8715e57d50195daf7c8e6f1509e617b4e692af1108939", size = 358827, upload-time = "2025-10-17T11:29:28.698Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d0/b3cc20ff5340775ea3bbaa0d665518eddecd4266ba7244c9cb480c0c82ec/jiter-0.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43b30c8154ded5845fa454ef954ee67bfccce629b2dea7d01f795b42bc2bda54", size = 385171, upload-time = "2025-10-17T11:29:30.078Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bc/94dd1f3a61f4dc236f787a097360ec061ceeebebf4ea120b924d91391b10/jiter-0.11.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:586cafbd9dd1f3ce6a22b4a085eaa6be578e47ba9b18e198d4333e598a91db2d", size = 518359, upload-time = "2025-10-17T11:29:31.464Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8c/12ee132bd67e25c75f542c227f5762491b9a316b0dad8e929c95076f773c/jiter-0.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:677cc2517d437a83bb30019fd4cf7cad74b465914c56ecac3440d597ac135250", size = 509205, upload-time = "2025-10-17T11:29:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/39/d5/9de848928ce341d463c7e7273fce90ea6d0ea4343cd761f451860fa16b59/jiter-0.11.1-cp312-cp312-win32.whl", hash = "sha256:fa992af648fcee2b850a3286a35f62bbbaeddbb6dbda19a00d8fbc846a947b6e", size = 205448, upload-time = "2025-10-17T11:29:34.217Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/8002d78637e05009f5e3fb5288f9d57d65715c33b5d6aa20fd57670feef5/jiter-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88b5cae9fa51efeb3d4bd4e52bfd4c85ccc9cac44282e2a9640893a042ba4d87", size = 204285, upload-time = "2025-10-17T11:29:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a2/bb24d5587e4dff17ff796716542f663deee337358006a80c8af43ddc11e5/jiter-0.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:9a6cae1ab335551917f882f2c3c1efe7617b71b4c02381e4382a8fc80a02588c", size = 188712, upload-time = "2025-10-17T11:29:37.027Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4b/e4dd3c76424fad02a601d570f4f2a8438daea47ba081201a721a903d3f4c/jiter-0.11.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:71b6a920a5550f057d49d0e8bcc60945a8da998019e83f01adf110e226267663", size = 305272, upload-time = "2025-10-17T11:29:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/83/2cd3ad5364191130f4de80eacc907f693723beaab11a46c7d155b07a092c/jiter-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b3de72e925388453a5171be83379549300db01284f04d2a6f244d1d8de36f94", size = 314038, upload-time = "2025-10-17T11:29:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3c/8e67d9ba524e97d2f04c8f406f8769a23205026b13b0938d16646d6e2d3e/jiter-0.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc19dd65a2bd3d9c044c5b4ebf657ca1e6003a97c0fc10f555aa4f7fb9821c00", size = 345977, upload-time = "2025-10-17T11:29:42.009Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/489ce64d992c29bccbffabb13961bbb0435e890d7f2d266d1f3df5e917d2/jiter-0.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d58faaa936743cd1464540562f60b7ce4fd927e695e8bc31b3da5b914baa9abd", size = 364503, upload-time = "2025-10-17T11:29:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c0/e321dd83ee231d05c8fe4b1a12caf1f0e8c7a949bf4724d58397104f10f2/jiter-0.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:902640c3103625317291cb73773413b4d71847cdf9383ba65528745ff89f1d14", size = 487092, upload-time = "2025-10-17T11:29:44.835Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/8f24ec49c8d37bd37f34ec0112e0b1a3b4b5a7b456c8efff1df5e189ad43/jiter-0.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30405f726e4c2ed487b176c09f8b877a957f535d60c1bf194abb8dadedb5836f", size = 376328, upload-time = "2025-10-17T11:29:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/7f/70/ded107620e809327cf7050727e17ccfa79d6385a771b7fe38fb31318ef00/jiter-0.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3217f61728b0baadd2551844870f65219ac4a1285d5e1a4abddff3d51fdabe96", size = 356632, upload-time = "2025-10-17T11:29:47.454Z" }, + { url = "https://files.pythonhosted.org/packages/19/53/c26f7251613f6a9079275ee43c89b8a973a95ff27532c421abc2a87afb04/jiter-0.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1364cc90c03a8196f35f396f84029f12abe925415049204446db86598c8b72c", size = 384358, upload-time = "2025-10-17T11:29:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/84/16/e0f2cc61e9c4d0b62f6c1bd9b9781d878a427656f88293e2a5335fa8ff07/jiter-0.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:53a54bf8e873820ab186b2dca9f6c3303f00d65ae5e7b7d6bda1b95aa472d646", size = 517279, upload-time = "2025-10-17T11:29:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/4cd095eaee68961bca3081acbe7c89e12ae24a5dae5fd5d2a13e01ed2542/jiter-0.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e29aca023627b0e0c2392d4248f6414d566ff3974fa08ff2ac8dbb96dfee92a", size = 508276, upload-time = "2025-10-17T11:29:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/25/f459240e69b0e09a7706d96ce203ad615ca36b0fe832308d2b7123abf2d0/jiter-0.11.1-cp313-cp313-win32.whl", hash = "sha256:f153e31d8bca11363751e875c0a70b3d25160ecbaee7b51e457f14498fb39d8b", size = 205593, upload-time = "2025-10-17T11:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/7c/16/461bafe22bae79bab74e217a09c907481a46d520c36b7b9fe71ee8c9e983/jiter-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:f773f84080b667c69c4ea0403fc67bb08b07e2b7ce1ef335dea5868451e60fed", size = 203518, upload-time = "2025-10-17T11:29:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/7b/72/c45de6e320edb4fa165b7b1a414193b3cae302dd82da2169d315dcc78b44/jiter-0.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:635ecd45c04e4c340d2187bcb1cea204c7cc9d32c1364d251564bf42e0e39c2d", size = 188062, upload-time = "2025-10-17T11:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/4a57922437ca8753ef823f434c2dec5028b237d84fa320f06a3ba1aec6e8/jiter-0.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d892b184da4d94d94ddb4031296931c74ec8b325513a541ebfd6dfb9ae89904b", size = 313814, upload-time = "2025-10-17T11:29:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/76/50/62a0683dadca25490a4bedc6a88d59de9af2a3406dd5a576009a73a1d392/jiter-0.11.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa22c223a3041dacb2fcd37c70dfd648b44662b4a48e242592f95bda5ab09d58", size = 344987, upload-time = "2025-10-17T11:30:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/da/00/2355dbfcbf6cdeaddfdca18287f0f38ae49446bb6378e4a5971e9356fc8a/jiter-0.11.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330e8e6a11ad4980cd66a0f4a3e0e2e0f646c911ce047014f984841924729789", size = 356399, upload-time = "2025-10-17T11:30:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/c9/07/c2bd748d578fa933d894a55bff33f983bc27f75fc4e491b354bef7b78012/jiter-0.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:09e2e386ebf298547ca3a3704b729471f7ec666c2906c5c26c1a915ea24741ec", size = 203289, upload-time = "2025-10-17T11:30:03.656Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ee/ace64a853a1acbd318eb0ca167bad1cf5ee037207504b83a868a5849747b/jiter-0.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:fe4a431c291157e11cee7c34627990ea75e8d153894365a3bc84b7a959d23ca8", size = 188284, upload-time = "2025-10-17T11:30:05.046Z" }, + { url = "https://files.pythonhosted.org/packages/8d/00/d6006d069e7b076e4c66af90656b63da9481954f290d5eca8c715f4bf125/jiter-0.11.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0fa1f70da7a8a9713ff8e5f75ec3f90c0c870be6d526aa95e7c906f6a1c8c676", size = 304624, upload-time = "2025-10-17T11:30:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/fc/45/4a0e31eb996b9ccfddbae4d3017b46f358a599ccf2e19fbffa5e531bd304/jiter-0.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:569ee559e5046a42feb6828c55307cf20fe43308e3ae0d8e9e4f8d8634d99944", size = 315042, upload-time = "2025-10-17T11:30:08.87Z" }, + { url = "https://files.pythonhosted.org/packages/e7/91/22f5746f5159a28c76acdc0778801f3c1181799aab196dbea2d29e064968/jiter-0.11.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69955fa1d92e81987f092b233f0be49d4c937da107b7f7dcf56306f1d3fcce9", size = 346357, upload-time = "2025-10-17T11:30:10.222Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4f/57620857d4e1dc75c8ff4856c90cb6c135e61bff9b4ebfb5dc86814e82d7/jiter-0.11.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:090f4c9d4a825e0fcbd0a2647c9a88a0f366b75654d982d95a9590745ff0c48d", size = 365057, upload-time = "2025-10-17T11:30:11.585Z" }, + { url = "https://files.pythonhosted.org/packages/ce/34/caf7f9cc8ae0a5bb25a5440cc76c7452d264d1b36701b90fdadd28fe08ec/jiter-0.11.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf3d8cedf9e9d825233e0dcac28ff15c47b7c5512fdfe2e25fd5bbb6e6b0cee", size = 487086, upload-time = "2025-10-17T11:30:13.052Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/85b5857c329d533d433fedf98804ebec696004a1f88cabad202b2ddc55cf/jiter-0.11.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2aa9b1958f9c30d3d1a558b75f0626733c60eb9b7774a86b34d88060be1e67fe", size = 376083, upload-time = "2025-10-17T11:30:14.416Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/2d9f973f828226e6faebdef034097a2918077ea776fb4d88489949024787/jiter-0.11.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42d1ca16590b768c5e7d723055acd2633908baacb3628dd430842e2e035aa90", size = 357825, upload-time = "2025-10-17T11:30:15.765Z" }, + { url = "https://files.pythonhosted.org/packages/f4/55/848d4dabf2c2c236a05468c315c2cb9dc736c5915e65449ccecdba22fb6f/jiter-0.11.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5db4c2486a023820b701a17aec9c5a6173c5ba4393f26662f032f2de9c848b0f", size = 383933, upload-time = "2025-10-17T11:30:17.34Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6c/204c95a4fbb0e26dfa7776c8ef4a878d0c0b215868011cc904bf44f707e2/jiter-0.11.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4573b78777ccfac954859a6eff45cbd9d281d80c8af049d0f1a3d9fc323d5c3a", size = 517118, upload-time = "2025-10-17T11:30:18.684Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/09956644ea5a2b1e7a2a0f665cb69a973b28f4621fa61fc0c0f06ff40a31/jiter-0.11.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7593ac6f40831d7961cb67633c39b9fef6689a211d7919e958f45710504f52d3", size = 508194, upload-time = "2025-10-17T11:30:20.719Z" }, + { url = "https://files.pythonhosted.org/packages/09/49/4d1657355d7f5c9e783083a03a3f07d5858efa6916a7d9634d07db1c23bd/jiter-0.11.1-cp314-cp314-win32.whl", hash = "sha256:87202ec6ff9626ff5f9351507def98fcf0df60e9a146308e8ab221432228f4ea", size = 203961, upload-time = "2025-10-17T11:30:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/76/bd/f063bd5cc2712e7ca3cf6beda50894418fc0cfeb3f6ff45a12d87af25996/jiter-0.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:a5dd268f6531a182c89d0dd9a3f8848e86e92dfff4201b77a18e6b98aa59798c", size = 202804, upload-time = "2025-10-17T11:30:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/52/ca/4d84193dfafef1020bf0bedd5e1a8d0e89cb67c54b8519040effc694964b/jiter-0.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:5d761f863f912a44748a21b5c4979c04252588ded8d1d2760976d2e42cd8d991", size = 188001, upload-time = "2025-10-17T11:30:24.915Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/3b05e5c9d32efc770a8510eeb0b071c42ae93a5b576fd91cee9af91689a1/jiter-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2cc5a3965285ddc33e0cab933e96b640bc9ba5940cea27ebbbf6695e72d6511c", size = 312561, upload-time = "2025-10-17T11:30:26.742Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/335822eb216154ddb79a130cbdce88fdf5c3e2b43dc5dba1fd95c485aaf5/jiter-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b572b3636a784c2768b2342f36a23078c8d3aa6d8a30745398b1bab58a6f1a8", size = 344551, upload-time = "2025-10-17T11:30:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/31/6d/a0bed13676b1398f9b3ba61f32569f20a3ff270291161100956a577b2dd3/jiter-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad93e3d67a981f96596d65d2298fe8d1aa649deb5374a2fb6a434410ee11915e", size = 363051, upload-time = "2025-10-17T11:30:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/a4/03/313eda04aa08545a5a04ed5876e52f49ab76a4d98e54578896ca3e16313e/jiter-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a83097ce379e202dcc3fe3fc71a16d523d1ee9192c8e4e854158f96b3efe3f2f", size = 485897, upload-time = "2025-10-17T11:30:31.429Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/a1011b9d325e40b53b1b96a17c010b8646013417f3902f97a86325b19299/jiter-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7042c51e7fbeca65631eb0c332f90c0c082eab04334e7ccc28a8588e8e2804d9", size = 375224, upload-time = "2025-10-17T11:30:33.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/1b45026b19dd39b419e917165ff0ea629dbb95f374a3a13d2df95e40a6ac/jiter-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a68d679c0e47649a61df591660507608adc2652442de7ec8276538ac46abe08", size = 356606, upload-time = "2025-10-17T11:30:34.572Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9acb0e54d6a8ba59ce923a180ebe824b4e00e80e56cefde86cc8e0a948be/jiter-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b0da75dbf4b6ec0b3c9e604d1ee8beaf15bc046fff7180f7d89e3cdbd3bb51", size = 384003, upload-time = "2025-10-17T11:30:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2b/e5a5fe09d6da2145e4eed651e2ce37f3c0cf8016e48b1d302e21fb1628b7/jiter-0.11.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:69dd514bf0fa31c62147d6002e5ca2b3e7ef5894f5ac6f0a19752385f4e89437", size = 516946, upload-time = "2025-10-17T11:30:37.425Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fe/db936e16e0228d48eb81f9934e8327e9fde5185e84f02174fcd22a01be87/jiter-0.11.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:bb31ac0b339efa24c0ca606febd8b77ef11c58d09af1b5f2be4c99e907b11111", size = 507614, upload-time = "2025-10-17T11:30:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/86/db/c4438e8febfb303486d13c6b72f5eb71cf851e300a0c1f0b4140018dd31f/jiter-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b2ce0d6156a1d3ad41da3eec63b17e03e296b78b0e0da660876fccfada86d2f7", size = 204043, upload-time = "2025-10-17T11:30:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/36/59/81badb169212f30f47f817dfaabf965bc9b8204fed906fab58104ee541f9/jiter-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f4db07d127b54c4a2d43b4cf05ff0193e4f73e0dd90c74037e16df0b29f666e1", size = 204046, upload-time = "2025-10-17T11:30:41.692Z" }, + { url = "https://files.pythonhosted.org/packages/dd/01/43f7b4eb61db3e565574c4c5714685d042fb652f9eef7e5a3de6aafa943a/jiter-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28e4fdf2d7ebfc935523e50d1efa3970043cfaa161674fe66f9642409d001dfe", size = 188069, upload-time = "2025-10-17T11:30:43.23Z" }, + { url = "https://files.pythonhosted.org/packages/9d/51/bd41562dd284e2a18b6dc0a99d195fd4a3560d52ab192c42e56fe0316643/jiter-0.11.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:e642b5270e61dd02265866398707f90e365b5db2eb65a4f30c789d826682e1f6", size = 306871, upload-time = "2025-10-17T11:31:03.616Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/64e7f21dd357e8cd6b3c919c26fac7fc198385bbd1d85bb3b5355600d787/jiter-0.11.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:464ba6d000585e4e2fd1e891f31f1231f497273414f5019e27c00a4b8f7a24ad", size = 301454, upload-time = "2025-10-17T11:31:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/54bdc00da4ef39801b1419a01035bd8857983de984fd3776b0be6b94add7/jiter-0.11.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:055568693ab35e0bf3a171b03bb40b2dcb10352359e0ab9b5ed0da2bf1eb6f6f", size = 336801, upload-time = "2025-10-17T11:31:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/de/8f/87176ed071d42e9db415ed8be787ef4ef31a4fa27f52e6a4fbf34387bd28/jiter-0.11.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0c69ea798d08a915ba4478113efa9e694971e410056392f4526d796f136d3fa", size = 343452, upload-time = "2025-10-17T11:31:08.259Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bc/950dd7f170c6394b6fdd73f989d9e729bd98907bcc4430ef080a72d06b77/jiter-0.11.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:0d4d6993edc83cf75e8c6828a8d6ce40a09ee87e38c7bfba6924f39e1337e21d", size = 302626, upload-time = "2025-10-17T11:31:09.645Z" }, + { url = "https://files.pythonhosted.org/packages/3a/65/43d7971ca82ee100b7b9b520573eeef7eabc0a45d490168ebb9a9b5bb8b2/jiter-0.11.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f78d151c83a87a6cf5461d5ee55bc730dd9ae227377ac6f115b922989b95f838", size = 297034, upload-time = "2025-10-17T11:31:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/000e1e0c0c67e96557a279f8969487ea2732d6c7311698819f977abae837/jiter-0.11.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9022974781155cd5521d5cb10997a03ee5e31e8454c9d999dcdccd253f2353f", size = 337328, upload-time = "2025-10-17T11:31:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/71408b02c6133153336d29fa3ba53000f1e1a3f78bb2fc2d1a1865d2e743/jiter-0.11.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18c77aaa9117510d5bdc6a946baf21b1f0cfa58ef04d31c8d016f206f2118960", size = 343697, upload-time = "2025-10-17T11:31:13.773Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json-repair" +version = "0.55.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/71d6bb078d167c0d0959776cee6b6bb8d2ad843f512a5222d7151dde4955/json_repair-0.55.1.tar.gz", hash = "sha256:b27aa0f6bf2e5bf58554037468690446ef26f32ca79c8753282adb3df25fb888", size = 39231, upload-time = "2026-01-23T09:37:20.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/da/289ba9eb550ae420cfc457926f6c49b87cacf8083ee9927e96921888a665/json_repair-0.55.1-py3-none-any.whl", hash = "sha256:a1bcc151982a12bc3ef9e9528198229587b1074999cfe08921ab6333b0c8e206", size = 29743, upload-time = "2026-01-23T09:37:19.404Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "langfuse" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/c28a09b696a1b908cf59b201d01e69066aeab804163d8dba055811790ed5/langfuse-3.12.1.tar.gz", hash = "sha256:da3bf4c0469eab4305f88a63cbb5ef89cf7542abbbcc9136a35c1bc708810520", size = 232768, upload-time = "2026-01-27T06:11:24.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/a5752417d704831f8c9fc4d7ec070342dee21d781d92e6fe937e60912e61/langfuse-3.12.1-py3-none-any.whl", hash = "sha256:ccf091ed6b6e0d9d4dbc95ad5cbb0f60c4452ce95b18c114ed5896f4546af38f", size = 416999, upload-time = "2026-01-27T06:11:22.657Z" }, +] + +[[package]] +name = "litellm" +version = "1.81.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/f4/c109bc5504520baa7b96a910b619d1b1b5af6cb5c28053e53adfed83e3ab/litellm-1.81.5.tar.gz", hash = "sha256:599994651cbb64b8ee7cd3b4979275139afc6e426bdd4aa840a61121bb3b04c9", size = 13615436, upload-time = "2026-01-29T01:37:54.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/0f/5312b944208efeec5dcbf8e0ed956f8f7c430b0c6458301d206380c90b56/litellm-1.81.5-py3-none-any.whl", hash = "sha256:206505c5a0c6503e465154b9c979772be3ede3f5bf746d15b37dca5ae54d239f", size = 11950016, upload-time = "2026-01-29T01:37:52.6Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "openai" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pdf2image" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/d8/b280f01045555dc257b8153c00dee3bc75830f91a744cd5f84ef3a0a64b1/pdf2image-1.17.0.tar.gz", hash = "sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57", size = 12811, upload-time = "2024-01-07T20:33:01.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2", size = 11618, upload-time = "2024-01-07T20:32:59.957Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20251230" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, + { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, + { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, + { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +] + +[[package]] +name = "pillow-heif" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/96/e4bf7bde1de9908bb509212c2861bff857eb4be845ebf80f6b0b02b8650d/pillow_heif-1.2.0.tar.gz", hash = "sha256:dd5c818dfb4ec39a5093127f8c07bbb32ca81dbbd29c4ebeffd23222ccc76aa9", size = 17128367, upload-time = "2026-01-23T07:35:24.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a8/0ae69889946db5452547c2fa9c9e4deb391f453f7ad1613141d10ce21a1b/pillow_heif-1.2.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:e5cb7dd143e16bbcdcffe16cfef7de2cd3aab180200fa31c8c03ecc978a7c712", size = 4817972, upload-time = "2026-01-23T07:34:10.135Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/3ff4e33a14e4997d313f79f74d41632843c191ea52e9098461b2259206c9/pillow_heif-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e6be78939b5e9f40b9f91bd9e6f3aa7a6c16e0528a5e7551c49e2dd214015404", size = 3503919, upload-time = "2026-01-23T07:34:12.538Z" }, + { url = "https://files.pythonhosted.org/packages/42/70/efc282ef14dd66ec30fb7fe1e2a2d2361ca9a5f374199ce5cc50f71ff1aa/pillow_heif-1.2.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31ef2519df1546ee81b2e9723edf1d20f8d7058cc1f4a5b8f174a288e47a2027", size = 5843141, upload-time = "2026-01-23T07:34:14.711Z" }, + { url = "https://files.pythonhosted.org/packages/c3/83/e3983d9225d33d689353c0cf898e2e2ca3481be26d2bec67d12477743ffc/pillow_heif-1.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:665caf0ff9745bfd183eef6a0213217cb99e3567158ebda7d463f3639d9c0d91", size = 5576802, upload-time = "2026-01-23T07:34:16.342Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/abf8a5876c893a7309535bfe4ef104388ccefc0e699e70e28712e9aa554d/pillow_heif-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f54f5a527cb99666959281364868ce310fcc1f352564778737327125b06aef5", size = 6884808, upload-time = "2026-01-23T07:34:18.375Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/0e77a6478bee0eef6cde7b0236a5215a116bf5054121c4f4152709b19d7c/pillow_heif-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a15fe3d4d50e94c613fcf5d368a5e021fb5fbc8d81340fbf6c66dee16c006507", size = 6509365, upload-time = "2026-01-23T07:34:20.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/4b/c9458e7845f239c28bedc01fa59e5c060b83fa473add9d65eba06fd8a4cc/pillow_heif-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:1527c08c7d71a5634e67ee87459988b60a7ea741448b20b3ed8998a293b46424", size = 5483096, upload-time = "2026-01-23T07:34:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4f/e9227c80209899bcfaa7df154224683825b0ff3588749d428eb82312e6d8/pillow_heif-1.2.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:d68e8afafaf409995a06d5d42474a39e169623cc9c88773cadc13434d3960567", size = 4817969, upload-time = "2026-01-23T07:34:24.032Z" }, + { url = "https://files.pythonhosted.org/packages/87/bb/13a29b22ebf67ad08f2be7fd40c4fda4119f4c52a69488f58cb1f03ae7e3/pillow_heif-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34118fff10fbd577624ef56bfaee9ee23e59ebeb4fb0f5862ed68de1af72ffa4", size = 3503920, upload-time = "2026-01-23T07:34:26.072Z" }, + { url = "https://files.pythonhosted.org/packages/b9/29/f711a0b6c012d8dd81adc182e8e00e18dc3a37e4740d1a8bd6971383a922/pillow_heif-1.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b0cd89f3519b8c3bad4bcbbfb79ca1aca76c67315b734eef40ae5c13b1ac360", size = 5844908, upload-time = "2026-01-23T07:34:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/266496e390d965b6457bde0f127db16c301afe4a475d60e116a314407c15/pillow_heif-1.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10cccff039f094e82447386c846b328fcc2ed9f98ba9c9bced394f0ee55049a1", size = 5578442, upload-time = "2026-01-23T07:34:29.146Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/a82e97647a57da4206b98e090c8f327ee8c51bcc991420b3fdadae351a2e/pillow_heif-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b53709294ae091c59d4a0d096b1b1ae1148459811982c5782dc59c5ad428f3c7", size = 6886364, upload-time = "2026-01-23T07:34:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/4afffe8aaac87d3389acc527ee05bd53f76da8e30a17768eed0d48a5b4de/pillow_heif-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47e08f29b5badab96d04abb4be85e12f1a53ad7cef12a0e9722b51e67375c5bd", size = 6510890, upload-time = "2026-01-23T07:34:32.854Z" }, + { url = "https://files.pythonhosted.org/packages/71/f7/6590f9838e6d95edfb7896177af789afa664085c8a0e65488f0f175a89f6/pillow_heif-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1061dd104b6ab6682bd7ce859f17f4339e91c40083e9d2a2d087eb0a33767d34", size = 5483091, upload-time = "2026-01-23T07:34:34.997Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/76ef3ecb3c7dbfa72033eeded44c0d6dc963aada7f7fc5a6262e3381dc38/pillow_heif-1.2.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9e2d26deeba5a2f31ee92ae7e21cd07f8001cd4b2075585eac2f74f926527210", size = 4818206, upload-time = "2026-01-23T07:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8c/04fa8db3627c8d8255645eaead8f2900c0e389c20815d19fefbd8a342fde/pillow_heif-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b121733ea0f82c3de04b73958aa338acd98137ed3ed3bf7a16516e0bf8654782", size = 3503861, upload-time = "2026-01-23T07:34:38.916Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b8/500bc420d193314add5b2d3e67e2568215378f025a1fc78374f9fec217f3/pillow_heif-1.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdba80502ea990e66e5d1bde97f58746e8002998f6d627c8b483ddff2bc262a7", size = 5843480, upload-time = "2026-01-23T07:34:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/84d73f681bae63062ac8c03b8d5cecc80ffc8604a2284573d06cd6b2718d/pillow_heif-1.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f5fe73cbf68778a48ebf320f52b73b142e986e2b853b5f83d4b5d102d276d33", size = 5577848, upload-time = "2026-01-23T07:34:42.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/dc6be91840ce5dc3607793fbeff382813bc5f641ef33abd2fb6f769856ea/pillow_heif-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36516178cf3c1de0cbefa6461ca0a5c4f67380b7ee8e200106aefaf444f32613", size = 6885225, upload-time = "2026-01-23T07:34:44.622Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/e86f47bb49214a3c7f7c09c6198db03048ddaf3c548f1fe58b25ff7d17da/pillow_heif-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132994a444b8a5f2c5e64aa7d07b50405668198d8af5ba53af12732676e882d", size = 6510220, upload-time = "2026-01-23T07:34:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/04a44838085df912fbdf5cef0146ef4026b47995f09c84a25bfc679d10d7/pillow_heif-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:8e06bb5e9ccd4f4a8be9c618e071ed47bcf8246fd0d1f00c25ac98c4ad9fd8ff", size = 5483151, upload-time = "2026-01-23T07:34:49.002Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/cbd3444e0f5939cc8855f32ca4c080a9583d67437886a0e875d4ea5ef06e/pillow_heif-1.2.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3f8f4bdb05a91dcd2e1e17b197fd646a6c800ffc7b623e7835448bb438f86d2f", size = 4818203, upload-time = "2026-01-23T07:34:50.685Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/79d11e691ca87fbb982938011c2ad2362e8b7f591203b89893be6b47189c/pillow_heif-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:519754c800cce1b9d6188be93b9019e33f5ccdda7430cc6d6fa3d4512d39183e", size = 3503868, upload-time = "2026-01-23T07:34:52.84Z" }, + { url = "https://files.pythonhosted.org/packages/5a/65/ff06cb741df6ce581016a028bc750941304b40f8b25ab03fb1ea3384c5b0/pillow_heif-1.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec9b625cceaafde82ed018aecc2b3d1372f54e0683de9af6c1e1bba8708f8846", size = 5843490, upload-time = "2026-01-23T07:34:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5a/e3aa65ccaac6a524a736c6e6f2306516585e69596c53494a7788568c56dd/pillow_heif-1.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d3b9555e7587394acb3045418ef8d8cd7fe27b6b20ec08fef7b502bfe0e53c5", size = 5577898, upload-time = "2026-01-23T07:34:56.299Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c0/86a54165d6b6c8df8f4afc1b496c0e09ef99cb9204fbbfeff89551fb2183/pillow_heif-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b78a4b867466d1f6c53c6279b88bcd76d3c3c4b5ccec850eef0428860107e1d7", size = 6885267, upload-time = "2026-01-23T07:34:57.853Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/02817c20e214a10b8cfcaf84ad90c17270ac21d5bec5a1d35174d98e3438/pillow_heif-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8260e0e0679734749eeb1b92a527d62ba5ed8263a8da807514cf15f8e08dec2", size = 6510222, upload-time = "2026-01-23T07:34:59.933Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/cb85f3c416c6734464ef5a3c40936256b9eb3ef7789698ca11fde102a508/pillow_heif-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c28144e24bc8413c61f48a7887a6545aa3be59d3011b7fb22c315d2192a39ea6", size = 5483145, upload-time = "2026-01-23T07:35:02.027Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ad/a9b30dcd387b91bcf10b0223ba84c2f03d22e3257912cf06547c7fc0374e/pillow_heif-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65d6b0462524aaa63e14b8641d79a196fae769dc2e686ec8149f6fa987c42fe3", size = 4818235, upload-time = "2026-01-23T07:35:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/73/70/2bbdb8285e577f20c47266df34a5cbd2ddd2d23da6aa3e619f62425d8403/pillow_heif-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0ddd0e718a25ff7489e581473d7ce71141261381565a74df826d286b3a3bb260", size = 3503956, upload-time = "2026-01-23T07:35:05.217Z" }, + { url = "https://files.pythonhosted.org/packages/29/bc/14f735c620d8985e2707a81be8e49780682b87e28ab6344e46e78caddcc8/pillow_heif-1.2.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621ce2edf354979401aa9293153dd1a98c4ae34f50d3a643a3a7d53fb9744966", size = 5843684, upload-time = "2026-01-23T07:35:07.036Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/3a422b87682910e00ba8f426976bc215f175c07ef03b081082156f0b1ac8/pillow_heif-1.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efdae3f789315e02efe757b17d4c256d8dbe31dc59332a662ee4cf4010e5103f", size = 5578012, upload-time = "2026-01-23T07:35:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8c/dae4ab7457753061e75a055f2b77c768b3893f6803fd65a539701774da8c/pillow_heif-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95e41cd05df94760313db151273a66e4b44f44fe272788172446c4bd4c2b852", size = 6885396, upload-time = "2026-01-23T07:35:09.938Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0f/eeab7a6a3dea9f09218d4442e6db26c1e0deb36fe0da8b0bb3aca0a8f37e/pillow_heif-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:62ecc0b29796f2079a8c1dcaaaeecfd555ded52e8813de0c59a71a2bc46cb4b5", size = 6510345, upload-time = "2026-01-23T07:35:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/1e6e16e589ebb9e59a3c0624eecf817f6b6d9f385e0d0c5ab00bbddf0d21/pillow_heif-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6b03babd6da615f0e34be62a049f46a1d8a0203d5fafeecd68acd962b5397fab", size = 5640570, upload-time = "2026-01-23T07:35:13.306Z" }, + { url = "https://files.pythonhosted.org/packages/69/7a/8fcfc61894835e48b6f6ff53364e51cffbd5f5d403b3ddcc025add60dbc0/pillow_heif-1.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d44ff71793fa9c8dfcda3b1313853b0dbcf05f58d68333f5c7cdb4c99c502e1b", size = 4806400, upload-time = "2026-01-23T07:35:15.641Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/13e85304483d6785bd9ecc253a2377ab130fab3b074a04b92722f1c1d55e/pillow_heif-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:219703e0e0e7c2f90a463b88e125c6bc52b08b20b5344c6d83975afd0194d165", size = 3500409, upload-time = "2026-01-23T07:35:17.378Z" }, + { url = "https://files.pythonhosted.org/packages/99/d1/c98564619b3fffe64d139043d058d505736997e2915c808cb40a767d7d40/pillow_heif-1.2.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:502e9ad050479b08714987dbb5fedc4376a77993ed438992bf1d7e221225327d", size = 5803334, upload-time = "2026-01-23T07:35:18.885Z" }, + { url = "https://files.pythonhosted.org/packages/cf/84/6736a17afb909527ff464a703d33db03935c69c001be94cce881bf727f5d/pillow_heif-1.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b12b54f2dcc897f69ecd7fbb5b26803523e0537a3f3b022be21ae80608e47f05", size = 5533945, upload-time = "2026-01-23T07:35:20.407Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7a/36f247a9e71a03e4297fac25384cc8290218fe199b44e27b3a448b32f9e9/pillow_heif-1.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b5820aa35993514e0bb75631d3999a0c49a31914fe6a256d4c87da76109c23fb", size = 5483505, upload-time = "2026-01-23T07:35:22.087Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "posthog" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, +] + +[[package]] +name = "psycopg2" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/9d12bc8677c24dad342ec777529bce705b3e785fa05d85122b5502b9ab55/psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3", size = 379598, upload-time = "2025-10-10T11:14:46.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ba/b7672ed9d0be238265972ef52a7a8c9e9e815ca2a7dc19a1b2e4b5b637f0/psycopg2-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:103e857f46bb76908768ead4e2d0ba1d1a130e7b8ed77d3ae91e8b33481813e8", size = 2713725, upload-time = "2025-10-10T11:10:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/fe/d6dce306fd7b61e312757ba4d068617f562824b9c6d3e4a39fc578ea2814/psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb", size = 2713723, upload-time = "2025-10-10T11:10:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bf/635fbe5dd10ed200afbbfbe98f8602829252ca1cce81cc48fb25ed8dadc0/psycopg2-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:e03e4a6dbe87ff81540b434f2e5dc2bddad10296db5eea7bdc995bf5f4162938", size = 2713969, upload-time = "2025-10-10T11:10:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/88/5a/18c8cb13fc6908dc41a483d2c14d927a7a3f29883748747e8cb625da6587/psycopg2-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:8dc379166b5b7d5ea66dcebf433011dfc51a7bb8a5fc12367fa05668e5fc53c8", size = 2714048, upload-time = "2025-10-10T11:10:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, +] + +[[package]] +name = "py-ubjson" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" } + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pyfiglet" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce08d76110a8eec2fe22e7051c4b8ba3fa163a0b08c428/pyfiglet-1.0.4.tar.gz", hash = "sha256:db9c9940ed1bf3048deff534ed52ff2dafbbc2cd7610b17bb5eca1df6d4278ef", size = 1560615, upload-time = "2025-08-15T18:32:47.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/5c/fe9f95abd5eaedfa69f31e450f7e2768bef121dbdf25bcddee2cd3087a16/pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f", size = 1806118, upload-time = "2025-08-15T18:32:45.556Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pypdf" +version = "6.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" }, +] + +[[package]] +name = "pypdf2" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/83/173dab58beb6c7e772b838199014c173a2436018dd7cfde9bbf4a3be15da/pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885", size = 268742, upload-time = "2026-01-05T16:29:03.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/a4/6bb5b5918c7fc236ec426be8a0205a984fe0a26ae23d5e4dd497398a6571/pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540", size = 2763287, upload-time = "2026-01-05T16:28:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/3e/64/24b41b906006bf07099b095f0420ee1f01a3a83a899f3e3731e4da99c06a/pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48", size = 2303285, upload-time = "2026-01-05T16:28:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c0/3ec73f4ded83ba6c02acf6e9d228501759d5d74fe57f1b93849ab92dcc20/pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523", size = 2816066, upload-time = "2026-01-05T16:28:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/ca/e553b3b8b5c2cdc3d955cc313493ac27bbe63fc22624769d56ded585dd5e/pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907", size = 2945545, upload-time = "2026-01-05T16:28:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/615b776071e95c8570d579038256d0c77969ff2ff381e427be4ab8967f44/pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca", size = 2979892, upload-time = "2026-01-05T16:28:31.088Z" }, + { url = "https://files.pythonhosted.org/packages/df/10/27114199b765bdb7d19a9514c07036ad2fc3a579b910e7823ba167ead6de/pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27", size = 2765738, upload-time = "2026-01-05T16:28:33.18Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d7/2a3afa35e6c205a4f6264c33b8d2f659707989f93c30b336aa58575f66fa/pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75", size = 3064338, upload-time = "2026-01-05T16:28:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/6658755cf6e369bb51d0bccb81c51c300404fbe67c2f894c90000b6442dd/pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3", size = 3415059, upload-time = "2026-01-05T16:28:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/f86482134fa641deb1f524c45ec7ebd6fc8d404df40c5657ddfce528593e/pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987", size = 2998517, upload-time = "2026-01-05T16:28:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/40ab99425dcf503c172885904c5dc356c052bfdbd085f9f3cc920e0b8b25/pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481", size = 3673154, upload-time = "2026-01-05T16:28:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/67/0f7532f80825a7728a5cbff3f1104857f8f9fe49ebfd6cb25582a89ae8e1/pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8", size = 2965002, upload-time = "2026-01-05T16:28:42.143Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/c03d2a3d6621b77aac9604bce1c060de2af94950448787298501eac6c6a2/pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330", size = 4130530, upload-time = "2026-01-05T16:28:44.264Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/9ad1f958cbe35d4693ae87c09ebafda4bb3e4709c7ccaec86c1a829163a3/pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47", size = 3746568, upload-time = "2026-01-05T16:28:45.897Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e2/4d32310166c2d6955d924737df8b0a3e3efc8d133344a98b10f96320157d/pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686", size = 4336683, upload-time = "2026-01-05T16:28:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/38c337ff12a8cec4b00fd4fdb0a63a70597a344581e20b02addbd301ab56/pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285", size = 4375030, upload-time = "2026-01-05T16:28:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/a1/77/9d8de90c35d2fc383be8819bcde52f5821dacbd7404a0225e4010b99d080/pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f", size = 3928914, upload-time = "2026-01-05T16:28:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/a5/39/9d4a6fbd78fcb6803b0ea5e4952a31d6182a0aaa2609cfcd0eb88446fdb8/pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e", size = 4997777, upload-time = "2026-01-05T16:28:53.589Z" }, + { url = "https://files.pythonhosted.org/packages/9d/38/cdd4ed085c264234a59ad32df1dfe432c77a7403da2381e0fcc1ba60b74e/pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5", size = 4179895, upload-time = "2026-01-05T16:28:55.322Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/d2f40145c9012482699664f615d7ae540a346c84f68a8179449e69dcc4d8/pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9", size = 2993570, upload-time = "2026-01-05T16:28:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dc/1388ea650020c26ef3f68856b9227e7f153dcaf445e7e4674a0b8f26891e/pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574", size = 3102340, upload-time = "2026-01-05T16:28:59.933Z" }, + { url = "https://files.pythonhosted.org/packages/c8/71/a433668d33999b3aeb2c2dda18aaf24948e862ea2ee148078a35daac6c1c/pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12", size = 2940987, upload-time = "2026-01-05T16:29:01.511Z" }, +] + +[[package]] +name = "pytailwindcss" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/90/b83df1eae67f3f22c59ca3f6df67d7a2591bb27157f04a173bd1f164d3e0/pytailwindcss-0.3.0.tar.gz", hash = "sha256:1f71dd64020aacb40608dfe8725ca441c772016c344f757ccbc74b8b6143027d", size = 5527, upload-time = "2025-10-29T19:13:42.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/59/e7b86790c4558cfa0344c424219fe17d371b70273044b9a2d163ce1d3f44/pytailwindcss-0.3.0-py3-none-any.whl", hash = "sha256:a9b770ad3c0a0f40073052bcfe81a060b550fed38af614a472f5aa7bb85fa8aa", size = 7502, upload-time = "2025-10-29T19:13:41.109Z" }, +] + +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-repeat" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/d4/8b706b81b07b43081bd68a2c0359fe895b74bf664b20aca8005d2bb3be71/pytest_repeat-0.9.4-py3-none-any.whl", hash = "sha256:c1738b4e412a6f3b3b9e0b8b29fcd7a423e50f87381ad9307ef6f5a8601139f3", size = 4180, upload-time = "2025-04-07T14:59:51.492Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "qdrant-client" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112, upload-time = "2025-12-12T10:58:30.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/b1/d6d6e7737fe3d0eb2ac2ac337686420d538f83f28495acc3cc32201c0dbf/rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517", size = 1953508, upload-time = "2026-04-07T11:13:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7b/94c1c953ac818bdd88b43213a9d38e4a41e953b786af3c3b2444d4a8f96d/rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051", size = 1160895, upload-time = "2026-04-07T11:13:39.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/60/a67a7ca7c2532c6c1a4b5cd797917780eed43798b82c98b6df734a086c95/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f", size = 1382245, upload-time = "2026-04-07T11:13:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/95/ff/a42c9ce9f9e90ceb5b51136e0b8e8e6e5113ba0b45d986effbd671e7dddf/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b", size = 3163974, upload-time = "2026-04-07T11:13:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/11e2d41075e6e48b7dad373631b379b7e40491f71d5412c5a98d3c58f60f/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c", size = 1475540, upload-time = "2026-04-07T11:13:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/29/fa/09be143dcc22c79f09cf90168a574725dbda49f02cbbd55d0447da8bec86/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa", size = 2404128, upload-time = "2026-04-07T11:13:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/1aeb504cdcfde42881825e9c86f48238d4e01ba8a1530491e82eb17e5689/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c", size = 2508455, upload-time = "2026-04-07T11:13:48.726Z" }, + { url = "https://files.pythonhosted.org/packages/10/8e/b1b5eed8d887a29b0e18fd3222c46ca60fddfb528e7e1c41267ce42d5522/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397", size = 4274060, upload-time = "2026-04-07T11:13:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/7e5b0353693d4f47b8b0f96e941efc377cfb2034b67ef92d082ac4441a0f/rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504", size = 1727457, upload-time = "2026-04-07T11:13:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6e/f530a39b946fa71c009bc9c81fdb6b48a77bbc57ee8572ac0302b3bf6308/rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe", size = 1544657, upload-time = "2026-04-07T11:13:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/02fa075f9f59ff766d374fecbd042b3ac9782dcd5abc52d909a54f587eeb/rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d", size = 816587, upload-time = "2026-04-07T11:13:56.418Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, + { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, + { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, + { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, + { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, + { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rich" +version = "14.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "sarvamai" +version = "0.1.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/31/13f65e8533b667514e1cfe838d12a14494cbc5943fd8f0c101305127459b/sarvamai-0.1.26.tar.gz", hash = "sha256:d51a213c27feb33d65f5b71e4882dcdb873dc5e0d720390b7ba18d1bdeec2471", size = 113050, upload-time = "2026-03-06T16:40:36.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c9/c03a807ace9cafbfe26418be995e4959142a55313c9f26564586e111f31d/sarvamai-0.1.26-py3-none-any.whl", hash = "sha256:39e79ba0932f4501a2aa28f84fd2de64d34fc9a7af2b0d4ead1efa617517b3bd", size = 229057, upload-time = "2026-03-06T16:40:35.584Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" }, +] + +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shikshalokam-mohini-service" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "asgiref" }, + { name = "beautifulsoup4" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "celery" }, + { name = "channels" }, + { name = "channels-redis" }, + { name = "coreapi" }, + { name = "coreschema" }, + { name = "daphne" }, + { name = "deepeval" }, + { name = "django" }, + { name = "django-admin-rangefilter" }, + { name = "django-celery-results" }, + { name = "django-cors-headers" }, + { name = "django-countries" }, + { name = "django-crontab" }, + { name = "django-debug-toolbar" }, + { name = "django-extensions" }, + { name = "django-filter" }, + { name = "django-import-export" }, + { name = "django-jazzmin" }, + { name = "django-querycount" }, + { name = "django-redis" }, + { name = "django-s3-storage" }, + { name = "django-simple-history" }, + { name = "django-storages" }, + { name = "django-tailwind", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "django-tailwind", version = "4.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, + { name = "gevent" }, + { name = "google-api-python-client" }, + { name = "google-auth-oauthlib" }, + { name = "google-cloud-speech" }, + { name = "google-cloud-storage" }, + { name = "google-cloud-texttospeech" }, + { name = "google-cloud-translate" }, + { name = "import-export" }, + { name = "instructor" }, + { name = "jinja2" }, + { name = "json-repair" }, + { name = "kombu" }, + { name = "langfuse" }, + { name = "litellm" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "openai" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pdf2image" }, + { name = "pdfplumber" }, + { name = "pillow" }, + { name = "pillow-heif" }, + { name = "protobuf" }, + { name = "psycopg2" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "pydantic-settings" }, + { name = "pydub" }, + { name = "pyjwt" }, + { name = "pypdf" }, + { name = "pypdf2" }, + { name = "pytesseract" }, + { name = "python-dateutil" }, + { name = "python-docx" }, + { name = "python-dotenv" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "rapidfuzz" }, + { name = "redis" }, + { name = "requests" }, + { name = "retrying" }, + { name = "sarvamai" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sentry-sdk" }, + { name = "tablib" }, + { name = "tabulate" }, + { name = "tqdm" }, + { name = "uuid" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "asgiref", specifier = ">=3.11.0" }, + { name = "beautifulsoup4", specifier = ">=4.14.3" }, + { name = "boto3", specifier = ">=1.42.37" }, + { name = "botocore", specifier = ">=1.42.37" }, + { name = "celery", specifier = ">=5.6.2" }, + { name = "channels", specifier = ">=4.3.2" }, + { name = "channels-redis", specifier = ">=4.3.0" }, + { name = "coreapi", specifier = ">=2.3.3" }, + { name = "coreschema", specifier = ">=0.0.4" }, + { name = "daphne", specifier = ">=4.2.1" }, + { name = "deepeval", specifier = ">=3.8.2" }, + { name = "django", specifier = "==5.2.0" }, + { name = "django-admin-rangefilter", specifier = ">=0.13.5" }, + { name = "django-celery-results", specifier = ">=2.6.0" }, + { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-countries", specifier = ">=8.2.0" }, + { name = "django-crontab", specifier = ">=0.7.1" }, + { name = "django-debug-toolbar", specifier = ">=6.2.0" }, + { name = "django-extensions", specifier = ">=4.1" }, + { name = "django-filter", specifier = ">=25.2" }, + { name = "django-import-export", specifier = ">=4.4.0" }, + { name = "django-jazzmin", specifier = ">=3.0.1" }, + { name = "django-querycount", specifier = ">=0.8.3" }, + { name = "django-redis", specifier = ">=6.0.0" }, + { name = "django-s3-storage", specifier = ">=0.15.0" }, + { name = "django-simple-history", specifier = ">=3.11.0" }, + { name = "django-storages", specifier = ">=1.14.6" }, + { name = "django-tailwind", specifier = ">=4.2.0" }, + { name = "djangorestframework", specifier = ">=3.16.1" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, + { name = "gevent", specifier = ">=26.4.0" }, + { name = "google-api-python-client", specifier = ">=2.197.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.4.0" }, + { name = "google-cloud-speech", specifier = ">=2.36.0" }, + { name = "google-cloud-storage", specifier = ">=3.11.0" }, + { name = "google-cloud-texttospeech", specifier = ">=2.34.0" }, + { name = "google-cloud-translate", specifier = ">=3.24.0" }, + { name = "import-export", specifier = ">=0.3.1" }, + { name = "instructor", specifier = ">=1.14.5" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "json-repair", specifier = ">=0.55.1" }, + { name = "kombu", specifier = ">=5.6.2" }, + { name = "langfuse", specifier = ">=3.12.1" }, + { name = "litellm", specifier = ">=1.81.5" }, + { name = "markdown", specifier = ">=3.10.1" }, + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-material", specifier = ">=9.7.1" }, + { name = "openai", specifier = ">=2.16.0" }, + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "pdf2image", specifier = ">=1.17.0" }, + { name = "pdfplumber", specifier = ">=0.11.9" }, + { name = "pillow", specifier = ">=12.1.0" }, + { name = "pillow-heif", specifier = ">=1.2.0" }, + { name = "protobuf", specifier = ">=6.33.4" }, + { name = "psycopg2", specifier = ">=2.9.11" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pydantic-core", specifier = ">=2.41.5" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "pydub", specifier = ">=0.25.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "pypdf", specifier = ">=6.6.2" }, + { name = "pypdf2", specifier = ">=3.0.1" }, + { name = "pytesseract", specifier = ">=0.3.13" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "python-docx", specifier = ">=1.2.0" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "pytz", specifier = ">=2025.2" }, + { name = "qdrant-client", specifier = ">=1.16.2" }, + { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "redis", specifier = ">=7.1.0" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "retrying", specifier = ">=1.4.2" }, + { name = "sarvamai", specifier = "==0.1.26" }, + { name = "scikit-learn", specifier = ">=1.7.2" }, + { name = "sentry-sdk", specifier = ">=2.51.0" }, + { name = "tablib", specifier = ">=3.9.0" }, + { name = "tabulate", specifier = ">=0.9.0" }, + { name = "tqdm", specifier = ">=4.67.1" }, + { name = "uuid", specifier = ">=1.30" }, + { name = "uvicorn", specifier = ">=0.40.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.0" }] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "tablib" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/00/416d2ba54d7d58a7f7c61bf62dfeb48fd553cf49614daf83312f2d2c156e/tablib-3.9.0.tar.gz", hash = "sha256:1b6abd8edb0f35601e04c6161d79660fdcde4abb4a54f66cc9f9054bd55d5fe2", size = 125565, upload-time = "2025-10-15T18:21:56.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/6b/32e51d847148b299088fc42d3d896845fd09c5247190133ea69dbe71ba51/tablib-3.9.0-py3-none-any.whl", hash = "sha256:eda17cd0d4dda614efc0e710227654c60ddbeb1ca92cdcfc5c3bd1fc5f5a6e4a", size = 49580, upload-time = "2025-10-15T18:21:44.185Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, + { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] + +[[package]] +name = "txaio" +version = "25.9.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/20/2e7ccea9ab2dd824d0bd421d9364424afde3bb33863afb80cd9180335019/txaio-25.9.2.tar.gz", hash = "sha256:e42004a077c02eb5819ff004a4989e49db113836708430d59cb13d31bd309099", size = 50008, upload-time = "2025-09-25T22:21:07.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/2c/e276b80f73fc0411cefa1c1eeae6bc17955197a9c3e2b41b41f957322549/txaio-25.9.2-py3-none-any.whl", hash = "sha256:a23ce6e627d130e9b795cbdd46c9eaf8abd35e42d2401bb3fea63d38beda0991", size = 31293, upload-time = "2025-09-25T22:21:06.394Z" }, +] + +[[package]] +name = "txaio" +version = "25.12.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "u-msgpack-python" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" }, +] + +[[package]] +name = "ujson" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/8bf7a4fabfd01c7eed92d9b290930ce6d14910dec708e73538baa38885d1/ujson-5.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:446e8c11c06048611c9d29ef1237065de0af07cabdd97e6b5b527b957692ec25", size = 55248, upload-time = "2025-08-20T11:55:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/eeab0b8b641817031ede4f790db4c4942df44a12f44d72b3954f39c6a115/ujson-5.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16ccb973b7ada0455201808ff11d48fe9c3f034a6ab5bd93b944443c88299f89", size = 53157, upload-time = "2025-08-20T11:55:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/a4e7a41870797633423ea79618526747353fd7be9191f3acfbdee0bf264b/ujson-5.11.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3134b783ab314d2298d58cda7e47e7a0f7f71fc6ade6ac86d5dbeaf4b9770fa6", size = 57657, upload-time = "2025-08-20T11:55:05.169Z" }, + { url = "https://files.pythonhosted.org/packages/94/ae/4e0d91b8f6db7c9b76423b3649612189506d5a06ddd3b6334b6d37f77a01/ujson-5.11.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:185f93ebccffebc8baf8302c869fac70dd5dd78694f3b875d03a31b03b062cdb", size = 59780, upload-time = "2025-08-20T11:55:06.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cc/46b124c2697ca2da7c65c4931ed3cb670646978157aa57a7a60f741c530f/ujson-5.11.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d06e87eded62ff0e5f5178c916337d2262fdbc03b31688142a3433eabb6511db", size = 57307, upload-time = "2025-08-20T11:55:07.493Z" }, + { url = "https://files.pythonhosted.org/packages/39/eb/20dd1282bc85dede2f1c62c45b4040bc4c389c80a05983515ab99771bca7/ujson-5.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:181fb5b15703a8b9370b25345d2a1fd1359f0f18776b3643d24e13ed9c036d4c", size = 1036369, upload-time = "2025-08-20T11:55:09.192Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/80072439065d493e3a4b1fbeec991724419a1b4c232e2d1147d257cac193/ujson-5.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4df61a6df0a4a8eb5b9b1ffd673429811f50b235539dac586bb7e9e91994138", size = 1195738, upload-time = "2025-08-20T11:55:11.402Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7e/d77f9e9c039d58299c350c978e086a804d1fceae4fd4a1cc6e8d0133f838/ujson-5.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6eff24e1abd79e0ec6d7eae651dd675ddbc41f9e43e29ef81e16b421da896915", size = 1088718, upload-time = "2025-08-20T11:55:13.297Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f1/697559d45acc849cada6b3571d53522951b1a64027400507aabc6a710178/ujson-5.11.0-cp310-cp310-win32.whl", hash = "sha256:30f607c70091483550fbd669a0b37471e5165b317d6c16e75dba2aa967608723", size = 39653, upload-time = "2025-08-20T11:55:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/70b73a0f55abe0e6b8046d365d74230c20c5691373e6902a599b2dc79ba1/ujson-5.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:3d2720e9785f84312b8e2cb0c2b87f1a0b1c53aaab3b2af3ab817d54409012e0", size = 43720, upload-time = "2025-08-20T11:55:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5f/b19104afa455630b43efcad3a24495b9c635d92aa8f2da4f30e375deb1a2/ujson-5.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:85e6796631165f719084a9af00c79195d3ebf108151452fefdcb1c8bb50f0105", size = 38410, upload-time = "2025-08-20T11:55:17.556Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/80346b826349d60ca4d612a47cdf3533694e49b45e9d1c07071bb867a184/ujson-5.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7c46cb0fe5e7056b9acb748a4c35aa1b428025853032540bb7e41f46767321f", size = 55248, upload-time = "2025-08-20T11:55:19.033Z" }, + { url = "https://files.pythonhosted.org/packages/57/df/b53e747562c89515e18156513cc7c8ced2e5e3fd6c654acaa8752ffd7cd9/ujson-5.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8951bb7a505ab2a700e26f691bdfacf395bc7e3111e3416d325b513eea03a58", size = 53156, upload-time = "2025-08-20T11:55:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/41/b8/ab67ec8c01b8a3721fd13e5cb9d85ab2a6066a3a5e9148d661a6870d6293/ujson-5.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952c0be400229940248c0f5356514123d428cba1946af6fa2bbd7503395fef26", size = 57657, upload-time = "2025-08-20T11:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/fb84f27cd80a2c7e2d3c6012367aecade0da936790429801803fa8d4bffc/ujson-5.11.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:94fcae844f1e302f6f8095c5d1c45a2f0bfb928cccf9f1b99e3ace634b980a2a", size = 59779, upload-time = "2025-08-20T11:55:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/48706f7c1e917ecb97ddcfb7b1d756040b86ed38290e28579d63bd3fcc48/ujson-5.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0ec1646db172beb8d3df4c32a9d78015e671d2000af548252769e33079d9a6", size = 57284, upload-time = "2025-08-20T11:55:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ce/48877c6eb4afddfd6bd1db6be34456538c07ca2d6ed233d3f6c6efc2efe8/ujson-5.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:da473b23e3a54448b008d33f742bcd6d5fb2a897e42d1fc6e7bf306ea5d18b1b", size = 1036395, upload-time = "2025-08-20T11:55:25.725Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7a/2c20dc97ad70cd7c31ad0596ba8e2cf8794d77191ba4d1e0bded69865477/ujson-5.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa6b3d4f1c0d3f82930f4cbd7fe46d905a4a9205a7c13279789c1263faf06dba", size = 1195731, upload-time = "2025-08-20T11:55:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/15/f5/ca454f2f6a2c840394b6f162fff2801450803f4ff56c7af8ce37640b8a2a/ujson-5.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4843f3ab4fe1cc596bb7e02228ef4c25d35b4bb0809d6a260852a4bfcab37ba3", size = 1088710, upload-time = "2025-08-20T11:55:29.426Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d3/9ba310e07969bc9906eb7548731e33a0f448b122ad9705fed699c9b29345/ujson-5.11.0-cp311-cp311-win32.whl", hash = "sha256:e979fbc469a7f77f04ec2f4e853ba00c441bf2b06720aa259f0f720561335e34", size = 39648, upload-time = "2025-08-20T11:55:31.194Z" }, + { url = "https://files.pythonhosted.org/packages/57/f7/da05b4a8819f1360be9e71fb20182f0bb3ec611a36c3f213f4d20709e099/ujson-5.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:683f57f0dd3acdd7d9aff1de0528d603aafcb0e6d126e3dc7ce8b020a28f5d01", size = 43717, upload-time = "2025-08-20T11:55:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/f3f9ac0f24f00a623a48d97dc3814df5c2dc368cfb00031aa4141527a24b/ujson-5.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:7855ccea3f8dad5e66d8445d754fc1cf80265a4272b5f8059ebc7ec29b8d0835", size = 38402, upload-time = "2025-08-20T11:55:33.641Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" }, + { url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" }, + { url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" }, + { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" }, + { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" }, + { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" }, + { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" }, + { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" }, + { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/30275aa2933430d8c0c4ead951cc4fdb922f575a349aa0b48a6f35449e97/ujson-5.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abae0fb58cc820092a0e9e8ba0051ac4583958495bfa5262a12f628249e3b362", size = 51206, upload-time = "2025-08-20T11:56:48.797Z" }, + { url = "https://files.pythonhosted.org/packages/c3/15/42b3924258eac2551f8f33fa4e35da20a06a53857ccf3d4deb5e5d7c0b6c/ujson-5.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fac6c0649d6b7c3682a0a6e18d3de6857977378dce8d419f57a0b20e3d775b39", size = 48907, upload-time = "2025-08-20T11:56:50.136Z" }, + { url = "https://files.pythonhosted.org/packages/94/7e/0519ff7955aba581d1fe1fb1ca0e452471250455d182f686db5ac9e46119/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b42c115c7c6012506e8168315150d1e3f76e7ba0f4f95616f4ee599a1372bbc", size = 50319, upload-time = "2025-08-20T11:56:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/209d90506b7d6c5873f82c5a226d7aad1a1da153364e9ebf61eff0740c33/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:86baf341d90b566d61a394869ce77188cc8668f76d7bb2c311d77a00f4bdf844", size = 56584, upload-time = "2025-08-20T11:56:52.89Z" }, + { url = "https://files.pythonhosted.org/packages/e9/97/bd939bb76943cb0e1d2b692d7e68629f51c711ef60425fa5bb6968037ecd/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4598bf3965fc1a936bd84034312bcbe00ba87880ef1ee33e33c1e88f2c398b49", size = 51588, upload-time = "2025-08-20T11:56:54.054Z" }, + { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835, upload-time = "2025-08-20T11:56:55.237Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid" +version = "1.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/63/f42f5aa951ebf2c8dac81f77a8edcc1c218640a2a35a03b9ff2d4aa64c3d/uuid-1.30.tar.gz", hash = "sha256:1f87cc004ac5120466f36c5beae48b4c48cc411968eed0eaecd3da82aa96193f", size = 5811, upload-time = "2007-05-26T11:13:24Z" } + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wheel" +version = "0.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/fa/6d9eb3a33998a3019d7eb4fa1802d01d6602fad90e0aea443e6e0fe8e49a/zope_interface-8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:788c293f3165964ec6527b2d861072c68eef53425213f36d3893ebee89a89623", size = 207541, upload-time = "2026-01-09T08:04:55.378Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/ad23c96fdee84cb1f768f6695dac187cc26e9038e01c69713ba0f7dc46ab/zope_interface-8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9a4e785097e741a1c953b3970ce28f2823bd63c00adc5d276f2981dd66c96c15", size = 208075, upload-time = "2026-01-09T08:04:57.118Z" }, + { url = "https://files.pythonhosted.org/packages/dd/35/1bfd5fec31a307f0cf4065ee74ade63858ded3e2a71e248f1508118fcc95/zope_interface-8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:16c69da19a06566664ddd4785f37cad5693a51d48df1515d264c20d005d322e2", size = 249528, upload-time = "2026-01-09T08:04:59.074Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3a/5d50b5fdb0f8226a2edff6adb7efdd3762ec95dff827dbab1761cb9a9e85/zope_interface-8.2-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c31acfa3d7cde48bec45701b0e1f4698daffc378f559bfb296837d8c834732f6", size = 254646, upload-time = "2026-01-09T08:05:00.964Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2a/ee7d675e151578eaf77828b8faac2b7ed9a69fead350bf5cf0e4afe7c73d/zope_interface-8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0723507127f8269b8f3f22663168f717e9c9742107d1b6c9f419df561b71aa6d", size = 255083, upload-time = "2026-01-09T08:05:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/99e2342f976c3700e142eddc01524e375a9e9078869a6885d9c72f3a3659/zope_interface-8.2-cp310-cp310-win_amd64.whl", hash = "sha256:3bf73a910bb27344def2d301a03329c559a79b308e1e584686b74171d736be4e", size = 211924, upload-time = "2026-01-09T08:05:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/98/97/9c2aa8caae79915ed64eb114e18816f178984c917aa9adf2a18345e4f2e5/zope_interface-8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c65ade7ea85516e428651048489f5e689e695c79188761de8c622594d1e13322", size = 208081, upload-time = "2026-01-09T08:05:06.623Z" }, + { url = "https://files.pythonhosted.org/packages/34/86/4e2fcb01a8f6780ac84923748e450af0805531f47c0956b83065c99ab543/zope_interface-8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef4b43659e1348f35f38e7d1a6bbc1682efde239761f335ffc7e31e798b65b", size = 208522, upload-time = "2026-01-09T08:05:07.986Z" }, + { url = "https://files.pythonhosted.org/packages/f6/eb/08e277da32ddcd4014922854096cf6dcb7081fad415892c2da1bedefbf02/zope_interface-8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dfc4f44e8de2ff4eba20af4f0a3ca42d3c43ab24a08e49ccd8558b7a4185b466", size = 255198, upload-time = "2026-01-09T08:05:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a1/b32484f3281a5dc83bc713ad61eca52c543735cdf204543172087a074a74/zope_interface-8.2-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8f094bfb49179ec5dc9981cb769af1275702bd64720ef94874d9e34da1390d4c", size = 259970, upload-time = "2026-01-09T08:05:11.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/bca0e8ae1e487d4093a8a7cfed2118aa2d4758c8cfd66e59d2af09d71f1c/zope_interface-8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d2bb8e7364e18f083bf6744ccf30433b2a5f236c39c95df8514e3c13007098ce", size = 261153, upload-time = "2026-01-09T08:05:13.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/e3ff2a708011e56b10b271b038d4cb650a8ad5b7d24352fe2edf6d6b187a/zope_interface-8.2-cp311-cp311-win_amd64.whl", hash = "sha256:6f4b4dfcfdfaa9177a600bb31cebf711fdb8c8e9ed84f14c61c420c6aa398489", size = 212330, upload-time = "2026-01-09T08:05:15.267Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a0/1e1fabbd2e9c53ef92b69df6d14f4adc94ec25583b1380336905dc37e9a0/zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c", size = 208785, upload-time = "2026-01-09T08:05:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2a/88d098a06975c722a192ef1fb7d623d1b57c6a6997cf01a7aabb45ab1970/zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa", size = 208976, upload-time = "2026-01-09T08:05:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e8/757398549fdfd2f8c89f32c82ae4d2f0537ae2a5d2f21f4a2f711f5a059f/zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d", size = 259411, upload-time = "2026-01-09T08:05:20.567Z" }, + { url = "https://files.pythonhosted.org/packages/91/af/502601f0395ce84dff622f63cab47488657a04d0065547df42bee3a680ff/zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a", size = 264859, upload-time = "2026-01-09T08:05:22.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/d2f765b9b4814a368a7c1b0ac23b68823c6789a732112668072fe596945d/zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2", size = 264398, upload-time = "2026-01-09T08:05:23.853Z" }, + { url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" }, + { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" }, + { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, +]