diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..05dcac1c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Keep the build context small and secrets out of the image. +.git +.github +.venv +venv +node_modules +frontend +android +docs +documents +tests +scripts +assets + +*.md +!README.md + +.env +*.keystore +*.jks + +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +htmlcov +.coverage + +data/uploads/* +data/*.db +backend/data/*.db diff --git a/.env.example b/.env.example index 61537d6e..4ef05364 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,72 @@ LOCAL_ML_QUANTIZE=false # CLIP model used for local inference LOCAL_CLIP_MODEL=openai/clip-vit-base-patch32 + + +# =============================== +# API server configuration +# =============================== + +# Comma-separated list of browser origins allowed to call the API. The Capacitor +# WebView origins are always appended, so the packaged Android app works without +# listing them here. +CORS_ORIGINS=http://localhost:5173 + +# Rate limiting. Both were declared in render.yaml long before anything read +# them; they are enforced now. +RATE_LIMIT_ENABLED=true +MAX_REQUESTS_PER_MINUTE=60 +# Tighter bucket for routes that call a paid inference API. +AI_REQUESTS_PER_MINUTE=12 +# In-process counters are correct for a single instance. Use redis:// beyond one. +RATE_LIMIT_STORAGE_URI=memory:// + +# Largest accepted upload, in megabytes. +MAX_UPLOAD_SIZE_MB=10 + +# Set on exactly ONE process. Telegram rejects a second long-poll on the same +# token with HTTP 409, so enabling this on a multi-worker web service breaks the +# bot. Run the API with it unset and a single dedicated worker with it set. +RUN_TELEGRAM_BOT=false + +# =============================== +# Frontend build +# =============================== + +# Absolute API base URL baked into the web build. Required for the Android app: +# a WebView has no dev proxy or Netlify redirect, so a relative /api path has +# nothing to resolve against, and Android blocks cleartext http:// by default. +VITE_API_URL=https://your-backend.onrender.com + +# =============================== +# Authentication +# =============================== + +# Shared administrative key for the endpoints that change state officials act +# on: escalating a grievance (which reassigns the responsible authority) and +# verifying an issue (which changes its status). Must be at least 32 +# characters; the server refuses shorter keys rather than pretending to be +# protected. If this is unset those endpoints answer 503, never 200 -- a +# missing secret must not read as "no authentication required". +# +# python -c "import secrets; print(secrets.token_urlsafe(48))" +ADMIN_API_KEY= + +# Secret for verifying bearer tokens used for user attribution. Optional: +# without it, requests carrying no token are still served anonymously, but a +# request that does carry one is rejected rather than silently downgraded. +JWT_SECRET= + +# If the configured DATABASE_URL cannot be reached at startup, the service falls +# back to local SQLite rather than failing every request. The fallback is logged +# at ERROR and reported by /health as "sqlite-fallback", and /health/ready stays +# 503, because a service that works is not the same as one configured correctly. +# +# It is a stopgap. On a platform with an ephemeral filesystem the SQLite file +# does not survive a restart, so reports collected while degraded can be lost. +# Set this to false for deployments that should refuse to start instead. +SQLITE_FALLBACK_ENABLED=true + +# Seconds to wait for the configured database before giving up on it. Kept +# short: this runs during startup. +DB_CONNECT_TIMEOUT=10 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..ac87c350 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,137 @@ +name: Android + +# Builds the Capacitor Android app. +# +# Pull requests get an unsigned debug APK so packaging breakage is caught before +# merge. Tags and manual runs produce a signed release bundle for Play Console. +# +# VITE_API_URL must be an absolute https:// URL. Inside a WebView there is no +# Vite dev proxy and no Netlify redirect, so a relative /api path has nothing to +# resolve against, and Android blocks cleartext http:// by default from API 28. + +on: + pull_request: + paths: + - 'frontend/**' + - '.github/workflows/android.yml' + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version_name: + description: 'Version name, e.g. 1.2.0' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ (github.event_name == 'pull_request') && 'debug APK' || 'release AAB' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + # Capacitor 8's Android tooling requires JDK 21. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: android-actions/setup-android@v3 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Verify the API base URL is absolute + env: + VITE_API_URL: ${{ vars.VITE_API_URL }} + run: | + if [ -z "$VITE_API_URL" ]; then + echo "::error::VITE_API_URL repository variable is not set. A packaged app cannot reach a relative /api path." + exit 1 + fi + case "$VITE_API_URL" in + https://*) ;; + *) echo "::error::VITE_API_URL must start with https:// (got '$VITE_API_URL'). Android blocks cleartext traffic."; exit 1 ;; + esac + + - name: Build web bundle and sync to Android + working-directory: frontend + env: + VITE_API_URL: ${{ vars.VITE_API_URL }} + run: npm run mobile:sync + + - name: Decode signing keystore + if: github.event_name != 'pull_request' + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -z "$KEYSTORE_BASE64" ]; then + echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set; a release build cannot be signed." + exit 1 + fi + echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore" + echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV" + + - name: Build debug APK + if: github.event_name == 'pull_request' + working-directory: frontend/android + run: ./gradlew --no-daemon assembleDebug + + - name: Build release bundle + if: github.event_name != 'pull_request' + working-directory: frontend/android + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + # Monotonic per run, which Play Console requires for every upload. + ANDROID_VERSION_CODE: ${{ github.run_number }} + ANDROID_VERSION_NAME: ${{ inputs.version_name || github.ref_name }} + run: ./gradlew --no-daemon bundleRelease + + - name: Confirm the bundle is signed + if: github.event_name != 'pull_request' + run: | + BUNDLE=frontend/android/app/build/outputs/bundle/release/app-release.aab + test -f "$BUNDLE" || { echo "::error::No bundle produced at $BUNDLE"; exit 1; } + # A v2-signed artifact carries META-INF/*.RSA (or .EC); its absence + # means Gradle silently fell back to the debug signing config. + if ! unzip -l "$BUNDLE" | grep -qE 'META-INF/.*\.(RSA|EC|DSA)'; then + echo "::error::Bundle is not signed with the release key." + exit 1 + fi + echo "Signed bundle: $(du -h "$BUNDLE" | cut -f1)" + + - name: Upload debug APK + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: vishwaguru-debug-apk + path: frontend/android/app/build/outputs/apk/debug/*.apk + retention-days: 7 + + - name: Upload release bundle + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: vishwaguru-release-aab + path: frontend/android/app/build/outputs/bundle/release/*.aab + retention-days: 30 diff --git a/.github/workflows/auto-deploy.yml b/.github/workflows/auto-deploy.yml index aa99c745..059112ab 100644 --- a/.github/workflows/auto-deploy.yml +++ b/.github/workflows/auto-deploy.yml @@ -1,10 +1,29 @@ name: Automated CI/CD Pipeline +# DISABLED daily cron on 2026-08-19. +# +# This workflow ran vishwaguru_pipeline.py, which squash-merges open pull +# requests through the GitHub API. Its gate did not check what it appeared to: +# +# * quality check = PR title >= 5 characters and body >= 10 characters +# * security check = grep the diff for a keyword list +# * "run tests" = `npm test` against the ROOT package.json, whose script is +# `jest tests/`. That collects one TypeScript file. It never ran the backend +# pytest suite or the frontend Jest suite. +# * "deploy and health check" = no docker-compose.yml and no manage.py exist, +# so it fell through to `python -m http.server`, then confirmed that static +# file server answered 200 -- and treated that as the application being +# healthy. +# +# So it merged to main daily on evidence that proved nothing. Together with +# auto-merge-jules.yml (deleted in the same pass) this is how the repository +# reached a state where the backend could not import, the frontend could not +# build, and 15 endpoints the frontend called did not exist. +# +# Merges now go through .github/workflows/ci.yml and human review. This is kept +# for manual dispatch only, for the deployment steps. + on: - schedule: - # Trigger at 2 AM UTC daily - - cron: '0 2 * * *' - # Allow manual triggering workflow_dispatch: jobs: diff --git a/.github/workflows/auto-merge-jules.yml b/.github/workflows/auto-merge-jules.yml deleted file mode 100644 index f47f0113..00000000 --- a/.github/workflows/auto-merge-jules.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Auto-Merge Jules PRs - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - pull-requests: write - contents: write - checks: read - -jobs: - auto-merge: - if: | - github.event.pull_request.user.login == 'google-labs-jules[bot]' || - github.event.pull_request.user.login == 'google-labs-jules' - runs-on: ubuntu-latest - steps: - - name: Wait for required checks - uses: lewagon/wait-on-check-action@v1.3.4 - with: - ref: ${{ github.event.pull_request.head.sha }} - repo-token: ${{ secrets.GITHUB_TOKEN }} - running-workflow-name: 'Auto-Merge Jules PRs' - wait-interval: 15 - - - name: Enable auto-merge - run: gh pr merge "${{ github.event.pull_request.number }}" --squash --auto - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0698d368 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,153 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + backend: + name: Backend (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.12'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install system deps + run: sudo apt-get update && sudo apt-get install -y libmagic1 + + - name: Install Python deps + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt + pip install -r requirements-dev.txt + + - name: Lint (ruff) + run: ruff check backend/ tests/ + + - name: Format check (ruff) + run: ruff format --check backend/ tests/ + + - name: Test + run: pytest -q + env: + ENVIRONMENT: test + DATABASE_URL: sqlite:///./data/test.db + + frontend: + name: Frontend (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['20', '22'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + working-directory: frontend + run: npm ci + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Test + working-directory: frontend + run: npm test -- --ci + + - name: Build + working-directory: frontend + run: npm run build + + - name: Upload web build + uses: actions/upload-artifact@v4 + with: + name: web-dist-node${{ matrix.node-version }} + path: frontend/dist + retention-days: 7 + + docker: + name: Backend image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: vishwaguru-api:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Boot the container and check /health + run: | + docker run -d --name api -p 8000:8000 -e ENVIRONMENT=test -e RUN_TELEGRAM_BOT=false vishwaguru-api:ci + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8000/health >/dev/null 2>&1; then + echo "Container healthy after ${i}s" + curl -sS http://127.0.0.1:8000/health + docker rm -f api + exit 0 + fi + sleep 1 + done + echo "::error::Container did not become healthy within 30s" + docker logs api + docker rm -f api + exit 1 + + security: + name: Security scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install scanners + run: pip install bandit pip-audit + + - name: Bandit (SAST) + run: bandit -r backend/ -ll -x backend/tests + + - name: pip-audit (dependency CVEs) + run: pip-audit -r backend/requirements.txt --strict + continue-on-error: true + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: npm audit + working-directory: frontend + run: npm audit --audit-level=high + continue-on-error: true diff --git a/.github/workflows/jules-daily-auto-upgrade.yml b/.github/workflows/jules-daily-auto-upgrade.yml index c7586f85..cf4183a2 100644 --- a/.github/workflows/jules-daily-auto-upgrade.yml +++ b/.github/workflows/jules-daily-auto-upgrade.yml @@ -1,28 +1,48 @@ -name: Jules Daily Auto-Upgrade +name: Jules Assisted Upgrade (manual) + +# DISABLED daily cron on 2026-08-19. +# Rationale: the scheduled run instructed Jules to force a file change every day +# ("You MUST append the current date to daily_streak.log"), then +# auto-merge-jules.yml squash-merged the result with no test gate. That loop +# produced backend/main_fixed.py (989 LOC dead code) and 15 frontend-called +# endpoints that do not exist in the backend. +# Manual dispatch only. Output is a draft PR that a human must review. on: - schedule: - - cron: '30 3 * * *' # 9:00 AM IST daily (GitHub cron runs in UTC) workflow_dispatch: + inputs: + prompt: + description: 'Task for Jules (leave blank to use the JULES_PROMPT secret)' + required: false + type: string + +permissions: + contents: read jobs: trigger-jules: runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write steps: - name: Trigger Jules session + env: + JULES_API_KEY: ${{ secrets.JULES_API_KEY }} + DEFAULT_PROMPT: ${{ secrets.JULES_PROMPT }} + INPUT_PROMPT: ${{ inputs.prompt }} run: | + PROMPT="${INPUT_PROMPT:-$DEFAULT_PROMPT}" + if [ -z "$PROMPT" ]; then + echo "::error::No prompt supplied and JULES_PROMPT secret is unset." + exit 1 + fi curl -sS -X POST 'https://jules.googleapis.com/v1alpha/sessions' \ - -H "X-Goog-Api-Key: ${{ secrets.JULES_API_KEY }}" \ + -H "X-Goog-Api-Key: ${JULES_API_KEY}" \ -H 'Content-Type: application/json' \ -d "$(jq -n \ - --arg prompt "${{ secrets.JULES_PROMPT }}. IMPORTANT: You MUST append the current date to 'daily_streak.log' in the root directory. Create the file if it doesn't exist. This ensures you always make at least one file change." \ + --arg prompt "$PROMPT" \ --arg repo "sources/github/${{ github.repository }}" \ '{ prompt: $prompt, sourceContext: { source: $repo, githubRepoContext: { startingBranch: "main" } }, automationMode: "AUTO_CREATE_PR", - title: "Daily Auto-Upgrade" + title: "Jules assisted upgrade (review required)" }')" diff --git a/.github/workflows/label-bot-prs.yml b/.github/workflows/label-bot-prs.yml new file mode 100644 index 00000000..a3f20808 --- /dev/null +++ b/.github/workflows/label-bot-prs.yml @@ -0,0 +1,31 @@ +name: Label bot PRs + +# Replaces auto-merge-jules.yml (deleted 2026-08-19). +# The old workflow squash-merged every google-labs-jules PR automatically with +# elevated write permissions on a fork-PR trigger, gated on +# wait-on-check-action -- but the repository had no CI checks at the time, so the +# gate passed vacuously and unreviewed code landed on main daily. +# Bot PRs are now labelled only. A human merges them, after CI passes. + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + label: + if: startsWith(github.event.pull_request.user.login, 'google-labs-jules') + runs-on: ubuntu-latest + steps: + - name: Add review-required label + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: ['bot-generated', 'needs-human-review'], + }); diff --git a/.gitignore b/.gitignore index c76abebc..bdacba05 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,18 @@ Thumbs.db # ===================== .vscode/ .idea/ + +# Python virtualenv +.venv/ + +# Android signing material. The release keystore and its passwords are supplied +# through environment variables from CI secrets and must never be committed. +*.keystore +*.jks +frontend/android/keystore.properties +frontend/android/app/google-services.json + +# Capacitor copies the built web bundle here on every sync. +frontend/android/app/src/main/assets/public/ +frontend/android/app/src/main/assets/capacitor.config.json +frontend/android/app/src/main/assets/capacitor.plugins.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f92fbbfb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# Production image for the VishwaGuru API. +# +# Two stages so build tooling (compilers for psycopg2, Pillow, numpy) never +# reaches the runtime image. Dependencies install from the committed lockfile, +# so an image built today and one built in six months contain the same +# packages -- backend/requirements.txt is generated by uv, not hand-edited. + +# ---------- build ---------- +FROM python:3.12-slim AS build + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +# Build-time only: headers and compilers for the wheels that lack a manylinux +# build for this platform. +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY backend/requirements.txt ./requirements.txt + +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install -r requirements.txt + +# ---------- runtime ---------- +FROM python:3.12-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:$PATH" \ + # Repo root, not backend/. Putting backend/ on the path lets `models` and + # `backend.models` load as two separate modules, which double-registers + # every SQLAlchemy table and makes backend.main fail at import. + PYTHONPATH=/app + +# libmagic is required by python-magic for upload type sniffing; libpq5 is the +# psycopg2 runtime. Neither pulls in a compiler. +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + libmagic1 \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Never run the service as root. +RUN useradd --create-home --uid 10001 vishwaguru + +COPY --from=build /opt/venv /opt/venv + +WORKDIR /app +COPY --chown=vishwaguru:vishwaguru backend/ ./backend/ +COPY --chown=vishwaguru:vishwaguru data/ ./data/ +COPY --chown=vishwaguru:vishwaguru alembic.ini ./alembic.ini + +# The SQLite fallback and uploaded images both write here. +RUN mkdir -p /app/data/uploads && chown -R vishwaguru:vishwaguru /app/data + +USER vishwaguru + +EXPOSE 8000 + +# Matches render.yaml's healthCheckPath. This endpoint is exempt from rate +# limiting so a busy service is never mistaken for an unhealthy one. +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/health || exit 1 + +# Schema migrations are NOT run on container start. Every replica would race to +# apply them, and a container that cannot migrate should fail the deploy rather +# than boot against a schema it half-changed. Run them once per deploy: +# +# docker run --rm -e DATABASE_URL=... vishwaguru-api:tag alembic upgrade head +# +# One worker by default. The Telegram poller is off unless RUN_TELEGRAM_BOT is +# set, so this image can be scaled horizontally; run exactly one separate +# instance with the flag set to serve the bot. +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..38ea9173 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,151 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/backend/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# The URL is set in backend/migrations/env.py from the application's own +# configuration, so there is no second place to keep in sync. +# sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 40b44f5b..00000000 Binary files a/backend/.coverage and /dev/null differ diff --git a/backend/__main__.py b/backend/__main__.py index 34419439..2bf1fba7 100644 --- a/backend/__main__.py +++ b/backend/__main__.py @@ -1,21 +1,25 @@ """ Entry point for running the backend as a module. -This allows running: +This allows running: - From root: python -m backend - From backend: python -m __main__ This will start the FastAPI application with uvicorn, which includes the Telegram bot via the lifespan context manager. """ + import os -import sys + import uvicorn if __name__ == "__main__": # Get the port from environment variable (Render provides PORT) port = int(os.environ.get("PORT", 8000)) - host = os.environ.get("HOST", "0.0.0.0") - + # nosec B104 / noqa: S104 - binding all interfaces is required inside a + # container or on Render, where the platform routes external traffic to the + # published port. Override with HOST to bind more narrowly. + host = os.environ.get("HOST", "0.0.0.0") # noqa: S104 # nosec B104 + # Determine the correct module path based on where we're running from # If we're in the backend directory, use "main:app" # If we're in the root directory, use "backend.main:app" @@ -24,11 +28,6 @@ app_module = "main:app" else: app_module = "backend.main:app" - + # Run uvicorn - uvicorn.run( - app_module, - host=host, - port=port, - log_level="info" - ) + uvicorn.run(app_module, host=host, port=port, log_level="info") diff --git a/backend/ai_factory.py b/backend/ai_factory.py index 92bb7f6e..1f7b961a 100644 --- a/backend/ai_factory.py +++ b/backend/ai_factory.py @@ -4,6 +4,7 @@ This module provides a factory pattern to easily switch between different AI service implementations (Gemini, Mock, etc.) based on configuration. """ + import os from typing import Literal diff --git a/backend/ai_interfaces.py b/backend/ai_interfaces.py index d777953e..1f7ed20f 100644 --- a/backend/ai_interfaces.py +++ b/backend/ai_interfaces.py @@ -4,9 +4,8 @@ This module defines abstract interfaces for AI services to reduce tight coupling and enable easier testing, mocking, and service provider switching. """ -from abc import ABC, abstractmethod -from typing import Dict, Optional, Protocol -import asyncio + +from typing import Protocol class ActionPlanService(Protocol): @@ -16,9 +15,9 @@ async def generate_action_plan( self, issue_description: str, category: str, - language: str = 'en', - image_path: Optional[str] = None - ) -> Dict[str, str]: + language: str = "en", + image_path: str | None = None, + ) -> dict[str, str]: """ Generate action plan with WhatsApp message and email draft. @@ -57,7 +56,7 @@ async def generate_mla_summary( district: str, assembly_constituency: str, mla_name: str, - issue_category: Optional[str] = None + issue_category: str | None = None, ) -> str: """ Generate a human-readable summary about an MLA. @@ -81,7 +80,7 @@ def __init__( self, action_plan_service: ActionPlanService, chat_service: ChatService, - mla_summary_service: MLASummaryService + mla_summary_service: MLASummaryService, ): self.action_plan_service = action_plan_service self.chat_service = chat_service @@ -89,7 +88,7 @@ def __init__( # Global service container instance -_ai_services: Optional[AIServiceContainer] = None +_ai_services: AIServiceContainer | None = None def get_ai_services() -> AIServiceContainer: @@ -102,12 +101,12 @@ def get_ai_services() -> AIServiceContainer: def initialize_ai_services( action_plan_service: ActionPlanService, chat_service: ChatService, - mla_summary_service: MLASummaryService + mla_summary_service: MLASummaryService, ) -> None: """Initialize the global AI services container.""" global _ai_services _ai_services = AIServiceContainer( action_plan_service=action_plan_service, chat_service=chat_service, - mla_summary_service=mla_summary_service + mla_summary_service=mla_summary_service, ) diff --git a/backend/ai_service.py b/backend/ai_service.py index 3a34dae9..836741e2 100644 --- a/backend/ai_service.py +++ b/backend/ai_service.py @@ -1,16 +1,15 @@ -import json -import os import base64 -import asyncio +import json import logging +import os import warnings -from typing import Optional, List, Dict, Any from functools import lru_cache +from typing import Any import httpx -from backend.retry_utils import exponential_backoff_retry from backend.exceptions import AIServiceException +from backend.retry_utils import exponential_backoff_retry # Configure logging logger = logging.getLogger(__name__) @@ -31,8 +30,7 @@ MODEL_NAME = NVIDIA_TEXT_MODEL VISION_MODEL = NVIDIA_VISION_MODEL logger.info( - f"AI Service: Using NVIDIA NIM — " - f"Text: {NVIDIA_TEXT_MODEL}, Vision: {NVIDIA_VISION_MODEL}" + f"AI Service: Using NVIDIA NIM — Text: {NVIDIA_TEXT_MODEL}, Vision: {NVIDIA_VISION_MODEL}" ) elif GEMINI_API_KEY: API_MODE = "gemini" @@ -42,6 +40,7 @@ VISION_MODEL = "gemini-1.5-flash" try: import google.generativeai as genai + genai.configure(api_key=GEMINI_API_KEY) warnings.filterwarnings("ignore", category=FutureWarning, module="google.generativeai") warnings.filterwarnings("ignore", category=DeprecationWarning, module="google.generativeai") @@ -74,7 +73,7 @@ def _load_responsibility_map() -> dict: """Load responsibility map for authority tagging.""" try: - with open(RESPONSIBILITY_MAP_PATH, "r") as f: + with open(RESPONSIBILITY_MAP_PATH) as f: return json.load(f) except Exception: return {} @@ -109,14 +108,16 @@ def _get_fallback_action_plan(issue_description: str, category: str) -> dict: # ── Image helpers ────────────────────────────────────────────────────────────── + def _encode_image_to_base64(image_path: str) -> tuple[str, str]: """ Read an image from disk, optionally resize to save bandwidth, and return (base64_string, mime_type). """ - import PIL.Image import io + import PIL.Image + img = PIL.Image.open(image_path) # Convert RGBA → RGB (JPEG doesn't support alpha) if img.mode in ("RGBA", "LA", "P"): @@ -150,6 +151,7 @@ def _encode_pil_image_to_base64(pil_image) -> tuple[str, str]: # ── NVIDIA NIM helpers ───────────────────────────────────────────────────────── + async def _nvidia_chat_completion(prompt: str, max_tokens: int = 1024) -> str: """Call NVIDIA NIM OpenAI-compatible chat completions (text-only).""" headers = { @@ -233,6 +235,7 @@ def _clean_json_response(text: str) -> str: # ── Action Plan ──────────────────────────────────────────────────────────────── + @exponential_backoff_retry(max_retries=3, base_delay=1.0, max_delay=10.0) async def _generate_action_plan_with_retry( issue_description: str, category: str, language: str = "en" @@ -277,28 +280,25 @@ async def generate_action_plan( logger.warning("No API key configured, using fallback action plan") return _get_fallback_action_plan(issue_description, category) + # Callers pass an image path positionally or by keyword, but the action-plan + # prompt built by _generate_action_plan_with_retry is text-only, so the + # image is accepted and ignored. It was previously parsed into a local that + # nothing ever read, which made it look as though the image informed the + # plan. Image analysis lives in analyze_issue_image(). language = "en" - image_path = None if len(args) == 1: arg = args[0] if isinstance(arg, str) and len(arg) == 2: language = arg - else: - image_path = arg elif len(args) >= 2: language = args[0] - image_path = args[1] if "language" in kwargs: language = kwargs["language"] - if "image_path" in kwargs: - image_path = kwargs["image_path"] try: - plan = await _generate_action_plan_with_retry( - issue_description, category, language - ) + plan = await _generate_action_plan_with_retry(issue_description, category, language) if "x_post" not in plan or not plan.get("x_post"): plan["x_post"] = build_x_post(issue_description, category) return plan @@ -309,10 +309,9 @@ async def generate_action_plan( # ── Chat Assistant ───────────────────────────────────────────────────────────── + @exponential_backoff_retry(max_retries=3, base_delay=1.0, max_delay=10.0) -async def _chat_with_civic_assistant_with_retry( - query: str, history_context: str = "" -) -> str: +async def _chat_with_civic_assistant_with_retry(query: str, history_context: str = "") -> str: prompt = f"""You are VishwaGuru, a helpful civic assistant for Indian citizens. {history_context} User Query: {query} @@ -333,9 +332,7 @@ async def _chat_with_civic_assistant_with_retry( raise AIServiceException("No AI backend configured") -async def chat_with_civic_assistant( - query: str, history: Optional[List[dict]] = None -) -> str: +async def chat_with_civic_assistant(query: str, history: list[dict] | None = None) -> str: """Chat with the civic assistant. Includes retry logic with exponential backoff.""" if API_MODE == "none": logger.warning("No API key configured, chat assistant offline") @@ -393,7 +390,7 @@ async def chat_with_civic_assistant( @exponential_backoff_retry(max_retries=2, base_delay=2.0, max_delay=15.0) -async def analyze_issue_image(image_path: str) -> Dict[str, Any]: +async def analyze_issue_image(image_path: str) -> dict[str, Any]: """ Analyze an uploaded image using NVIDIA NIM vision model (meta/llama-3.2-90b-vision-instruct) or Gemini multimodal. @@ -423,14 +420,12 @@ async def analyze_issue_image(image_path: str) -> Dict[str, Any]: elif API_MODE == "gemini": # ── Gemini multimodal ────────────────────────────────────────── - import PIL.Image import google.generativeai as genai + import PIL.Image img = PIL.Image.open(image_path) model = genai.GenerativeModel("gemini-1.5-flash") - response = await model.generate_content_async( - [_IMAGE_ANALYSIS_PROMPT, img] - ) + response = await model.generate_content_async([_IMAGE_ANALYSIS_PROMPT, img]) text_response = response.text.strip() else: raise AIServiceException("No AI backend configured") @@ -455,9 +450,7 @@ async def analyze_issue_image(image_path: str) -> Dict[str, Any]: @exponential_backoff_retry(max_retries=2, base_delay=2.0, max_delay=15.0) -async def analyze_issue_with_ai( - description: str, image_path: Optional[str] = None -) -> Dict[str, Any]: +async def analyze_issue_with_ai(description: str, image_path: str | None = None) -> dict[str, Any]: """ Analyze a civic issue description with optional image. Uses NVIDIA Vision model when image is provided. @@ -478,9 +471,7 @@ async def analyze_issue_with_ai( if has_image: # ── Vision model for image + text ────────────────────────── image_b64, mime = _encode_image_to_base64(image_path) - prompt = _ISSUE_ANALYSIS_WITH_IMAGE_PROMPT.format( - description=description - ) + prompt = _ISSUE_ANALYSIS_WITH_IMAGE_PROMPT.format(description=description) text_response = await _nvidia_vision_completion( prompt=prompt, image_b64=image_b64, diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 00000000..bcf17c4e --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,144 @@ +"""Authentication for privileged operations. + +Every endpoint in this service was publicly callable. For most of them that is +correct -- anonymous reporting is a feature of a civic platform, and abuse is +bounded by the rate limiter. Two are not: + + POST /api/grievances/{id}/escalate reassigns a grievance to a different + authority and writes an audit record + POST /api/issues/{id}/verify changes an issue's status + +Both change state that officials act on, so both now require a key. + +The design is deliberately small. There is no user table, no registration and +no password handling, because nothing in the product needs one yet: issues +carry a `user_email` for attribution only. Adding a full identity system to +protect two endpoints would be a larger attack surface than the one it closes. +`optional_user` exists so that when identity does arrive, callers can adopt it +without another refactor. + +Fail closed: if ADMIN_API_KEY is unset the protected endpoints answer 503 +rather than allowing the request. A missing secret must never read as +"no authentication required". +""" + +from __future__ import annotations + +import hmac +import logging +import os +from dataclasses import dataclass + +import jwt +from fastapi import Depends, Header, HTTPException, status + +logger = logging.getLogger(__name__) + +ADMIN_API_KEY_ENV = "ADMIN_API_KEY" +JWT_SECRET_ENV = "JWT_SECRET" # noqa: S105 - the NAME of an env var, not a secret +JWT_ALGORITHM = "HS256" + +# A key shorter than this is almost certainly a placeholder rather than a +# generated secret, and accepting it would give a false sense of protection. +MIN_API_KEY_LENGTH = 32 + + +@dataclass(frozen=True) +class AuthenticatedUser: + """Identity extracted from a bearer token.""" + + email: str | None + subject: str | None + + +def _configured_admin_key() -> str | None: + key = os.getenv(ADMIN_API_KEY_ENV, "").strip() + return key or None + + +def require_api_key(x_api_key: str | None = Header(default=None)) -> str: + """Guard privileged endpoints with a shared administrative key. + + Compared with hmac.compare_digest rather than ==, so the comparison does + not leak the key's length or contents through timing. + """ + expected = _configured_admin_key() + + if expected is None: + # Fail closed. An unset secret means the deployment is misconfigured, + # not that the endpoint is open. + logger.error( + "%s is not set; refusing privileged request rather than allowing it.", + ADMIN_API_KEY_ENV, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="This endpoint is not available: the server has no administrative key configured.", + ) + + if len(expected) < MIN_API_KEY_LENGTH: + logger.error( + "%s is shorter than %d characters; refusing privileged request.", + ADMIN_API_KEY_ENV, + MIN_API_KEY_LENGTH, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="This endpoint is not available: the configured administrative key is too weak.", + ) + + if not x_api_key or not hmac.compare_digest(x_api_key, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="A valid X-API-Key header is required for this operation.", + headers={"WWW-Authenticate": "ApiKey"}, + ) + + return x_api_key + + +def optional_user( + authorization: str | None = Header(default=None), +) -> AuthenticatedUser | None: + """Decode a bearer token when one is supplied, for attribution only. + + Returns None when no token is present, so public endpoints keep working + anonymously. A token that is present but invalid is rejected outright -- + silently treating a bad token as "anonymous" hides both client bugs and + tampering. + """ + if not authorization: + return None + + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header must be of the form 'Bearer '.", + ) + + secret = os.getenv(JWT_SECRET_ENV, "").strip() + if not secret: + logger.error("%s is not set; cannot verify the supplied token.", JWT_SECRET_ENV) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Token authentication is not configured on this server.", + ) + + try: + claims = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired." + ) from exc + except jwt.InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is not valid." + ) from exc + + return AuthenticatedUser(email=claims.get("email"), subject=claims.get("sub")) + + +# Convenience aliases so routes read as documentation. +RequireApiKey = Depends(require_api_key) +OptionalUser = Depends(optional_user) diff --git a/backend/bot.py b/backend/bot.py index 062d7b53..bc47909c 100644 --- a/backend/bot.py +++ b/backend/bot.py @@ -1,29 +1,40 @@ -import os -import logging import asyncio +import logging +import os import threading -from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove -from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler, MessageHandler, filters, ConversationHandler -from backend.database import engine, SessionLocal -from backend.models import Base, Issue +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update +from telegram.ext import ( + ApplicationBuilder, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) +from backend.database import SessionLocal +from backend.models import Issue # Enable logging logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) # States for ConversationHandler PHOTO, DESCRIPTION, CATEGORY = range(3) -# Initialize Database -Base.metadata.create_all(bind=engine) +# Schema creation is Alembic's job, not an import side effect. +# +# This ran at import time, and backend.main imports this module, so an +# unreachable database took the entire API process down before it could serve +# anything -- /health included. The bot writes to tables the migrations create; +# it does not create them. # Create a global application instance placeholder application = None + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( "Namaste! Welcome to VishwaGuru.\n" @@ -32,6 +43,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): ) return PHOTO + async def receive_photo(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.message.from_user photo_file = await update.message.photo[-1].get_file() @@ -45,25 +57,27 @@ async def receive_photo(update: Update, context: ContextTypes.DEFAULT_TYPE): await photo_file.download_to_drive(filename) # Store filename in context to save later - context.user_data['photo_path'] = filename + context.user_data["photo_path"] = filename await update.message.reply_text( "Photo received! Now, please describe the issue in a few words." ) return DESCRIPTION + async def receive_description(update: Update, context: ContextTypes.DEFAULT_TYPE): text = update.message.text - context.user_data['description'] = text + context.user_data["description"] = text categories = [["Road", "Water"], ["Streetlight", "Garbage"], ["College Infra", "Women Safety"]] await update.message.reply_text( "Got it. Which category does this belong to?", - reply_markup=ReplyKeyboardMarkup(categories, one_time_keyboard=True, resize_keyboard=True) + reply_markup=ReplyKeyboardMarkup(categories, one_time_keyboard=True, resize_keyboard=True), ) return CATEGORY + def save_issue_to_db(description, category, photo_path): """ Synchronous helper to save issue to DB. @@ -72,10 +86,7 @@ def save_issue_to_db(description, category, photo_path): db = SessionLocal() try: new_issue = Issue( - description=description, - category=category, - image_path=photo_path, - source='telegram' + description=description, category=category, image_path=photo_path, source="telegram" ) db.add(new_issue) db.commit() @@ -87,10 +98,11 @@ def save_issue_to_db(description, category, photo_path): finally: db.close() + async def receive_category(update: Update, context: ContextTypes.DEFAULT_TYPE): category = update.message.text - photo_path = context.user_data.get('photo_path') - description = context.user_data.get('description') + photo_path = context.user_data.get("photo_path") + description = context.user_data.get("description") try: # Save to Database using threadpool to prevent blocking the event loop @@ -101,7 +113,7 @@ async def receive_category(update: Update, context: ContextTypes.DEFAULT_TYPE): f"Thank you! Your issue has been reported.\n" f"Reference ID: #{issue_id}\n\n" f"We will generate an action plan for you soon.", - reply_markup=ReplyKeyboardRemove() + reply_markup=ReplyKeyboardRemove(), ) except Exception: await update.message.reply_text("Sorry, something went wrong while saving your issue.") @@ -109,30 +121,28 @@ async def receive_category(update: Update, context: ContextTypes.DEFAULT_TYPE): return ConversationHandler.END + async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( "Issue reporting cancelled.", reply_markup=ReplyKeyboardRemove() ) return ConversationHandler.END + # Global variable to hold the bot application -application = None +# --- Application construction ------------------------------------------------- -async def build_app(): - """Builds and returns the bot application.""" - token = os.environ.get("TELEGRAM_BOT_TOKEN") - if not token: - print("Warning: TELEGRAM_BOT_TOKEN environment variable not set. Bot will not start.") - # Return a dummy mock if token is missing so imports don't fail, - # but startup checks in main.py will handle it. - # Actually, for the purpose of 'import application' to work in main.py, - # we need to initialize 'application' at module level or provide a getter. - # But ApplicationBuilder() requires a token. - return None +logger = logging.getLogger(__name__) - app = ApplicationBuilder().token(token).build() +application = None +_bot_application = None +_bot_thread = None +_shutdown_event = None - conv_handler = ConversationHandler( + +def _build_conversation_handler() -> ConversationHandler: + """The bot's single conversation flow: photo -> description -> category.""" + return ConversationHandler( entry_points=[CommandHandler("start", start)], states={ PHOTO: [MessageHandler(filters.PHOTO, receive_photo)], @@ -142,70 +152,152 @@ async def build_app(): fallbacks=[CommandHandler("cancel", cancel)], ) - app.add_handler(conv_handler) + +class MockApplication: + """Stand-in used when TELEGRAM_BOT_TOKEN is absent. + + `backend.main` imports `application` unconditionally and awaits its + lifecycle methods, so this has to be an object rather than None. + """ + + class _Updater: + async def start_polling(self): + return None + + async def stop(self): + return None + + def __init__(self): + self.updater = self._Updater() + + async def initialize(self): + return None + + async def start(self): + return None + + async def stop(self): + return None + + async def shutdown(self): + return None + + +def _make_application(): + """Build a real Application when a token is configured, else a mock.""" + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + logger.warning("TELEGRAM_BOT_TOKEN not set - using MockApplication.") + return MockApplication() + app = ApplicationBuilder().token(token).build() + app.add_handler(_build_conversation_handler()) return app -# We try to build it at import time if token exists, -# otherwise we might need to lazy load it or handle it in main.py differently. -# Ideally, main.py should not import 'application' directly if it's conditional. -# But existing main.py did: 'from bot import application'. -# To support that, we need 'application' to be defined here. -try: + +async def build_app(): + """Async accessor kept for callers that expect a coroutine.""" token = os.environ.get("TELEGRAM_BOT_TOKEN") - if token: - application = ApplicationBuilder().token(token).build() - conv_handler = ConversationHandler( - entry_points=[CommandHandler("start", start)], - states={ - PHOTO: [MessageHandler(filters.PHOTO, receive_photo)], - DESCRIPTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_description)], - CATEGORY: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_category)], - }, - fallbacks=[CommandHandler("cancel", cancel)], - ) - application.add_handler(conv_handler) - else: - # Create a dummy object or None - # If None, main.py might crash if it tries to use it without check. - # main.py code: - # await application.initialize() - # So it expects an object. - class MockApp: - async def initialize(self): pass - class Updater: - async def start_polling(self): pass - async def stop(self): pass - updater = Updater() - async def start(self): pass - async def stop(self): pass - async def shutdown(self): pass - - application = MockApp() - print("Telegram Bot Token missing, using Mock Application.") - -except Exception as e: - print(f"Error building bot app at module level: {e}") + if not token: + return None + return _make_application() + + +try: + application = _make_application() +except Exception: + logger.exception("Error building bot application at import time") application = None + async def run_bot(): - """Legacy entry point, reused if needed""" - if application: - # If already built - return application - return await build_app() - -if __name__ == '__main__': - # For standalone bot testing + """Legacy entry point: start the polling loop on its own thread. + + Returns None because the bot runs in `_bot_thread`, not in the caller's + event loop. It previously returned the module-level `application`, which + handed callers an object whose lifecycle nothing owned. + """ start_bot_thread() + return None + + +# --- Threaded runner ---------------------------------------------------------- +# +# Running the bot's polling loop inside the FastAPI lifespan means every uvicorn +# worker opens its own long-poll against Telegram, and Telegram rejects the +# extras with HTTP 409 -- so the API could never scale past one worker. Owning +# the loop in a dedicated thread here lets the bot be started independently of +# the web process. + + +def start_bot_thread(): + """Start the polling loop on a background thread. Idempotent.""" + global _bot_thread, _shutdown_event, _bot_application + + if _bot_thread is not None and _bot_thread.is_alive(): + return _bot_thread + + if not os.environ.get("TELEGRAM_BOT_TOKEN"): + logger.warning("TELEGRAM_BOT_TOKEN not set - bot thread not started.") + return None - # Keep main thread alive + _shutdown_event = threading.Event() + + def _run() -> None: + global _bot_application + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + _bot_application = _make_application() + loop.run_until_complete(_bot_application.initialize()) + loop.run_until_complete(_bot_application.start()) + loop.run_until_complete(_bot_application.updater.start_polling()) + logger.info("Telegram bot polling started.") + while not _shutdown_event.is_set(): + loop.run_until_complete(asyncio.sleep(0.5)) + except Exception: + logger.exception("Telegram bot thread terminated with an error") + finally: + try: + if _bot_application is not None: + loop.run_until_complete(_bot_application.updater.stop()) + loop.run_until_complete(_bot_application.stop()) + loop.run_until_complete(_bot_application.shutdown()) + except Exception: + logger.exception("Error during Telegram bot shutdown") + finally: + loop.close() + logger.info("Telegram bot thread stopped.") + + _bot_thread = threading.Thread(target=_run, name="telegram-bot", daemon=True) + _bot_thread.start() + return _bot_thread + + +def stop_bot_thread(timeout: float = 10.0) -> None: + """Signal the polling loop to finish and wait for the thread to exit.""" + global _bot_thread, _bot_application + + if _shutdown_event is not None: + _shutdown_event.set() + + if _bot_thread is not None and _bot_thread.is_alive(): + _bot_thread.join(timeout=timeout) + if _bot_thread.is_alive(): + logger.error("Telegram bot thread did not stop within %ss", timeout) + + _bot_thread = None + # The Application has been shut down by the thread's finally block; holding + # a reference to a dead one lets callers use it as though it were live. + _bot_application = None + + +if __name__ == "__main__": + if start_bot_thread() is None: + raise SystemExit("Cannot start bot: TELEGRAM_BOT_TOKEN is not set.") try: - while True: - if not _bot_thread or not _bot_thread.is_alive(): - logging.error("Bot thread died unexpectedly") - break - asyncio.sleep(5) + while _bot_thread is not None and _bot_thread.is_alive(): + _bot_thread.join(timeout=5) except KeyboardInterrupt: - logging.info("Received interrupt signal") + logger.info("Received interrupt signal") finally: stop_bot_thread() diff --git a/backend/cache.py b/backend/cache.py index 37adc28a..540192e4 100644 --- a/backend/cache.py +++ b/backend/cache.py @@ -1,17 +1,17 @@ -import time import logging import threading -from typing import Any, Optional -from datetime import datetime, timedelta +import time +from typing import Any logger = logging.getLogger(__name__) + class ThreadSafeCache: """ Thread-safe cache implementation with TTL and memory management. Fixes race conditions and implements proper cache expiration. """ - + def __init__(self, ttl: int = 300, max_size: int = 100): self._data = {} self._timestamps = {} @@ -19,14 +19,14 @@ def __init__(self, ttl: int = 300, max_size: int = 100): self._max_size = max_size # Maximum number of cache entries self._lock = threading.RLock() # Reentrant lock for thread safety self._access_count = {} # Track access frequency for LRU eviction - - def get(self, key: str = "default") -> Optional[Any]: + + def get(self, key: str = "default") -> Any | None: """ Thread-safe get operation with automatic cleanup. """ with self._lock: current_time = time.time() - + # Check if key exists and is not expired if key in self._data and key in self._timestamps: if current_time - self._timestamps[key] < self._ttl: @@ -36,30 +36,30 @@ def get(self, key: str = "default") -> Optional[Any]: else: # Expired entry - remove it self._remove_key(key) - + return None - + def set(self, data: Any, key: str = "default") -> None: """ Thread-safe set operation with memory management. """ with self._lock: current_time = time.time() - + # Clean up expired entries before adding new one self._cleanup_expired() - + # If cache is full, evict least recently used entry if len(self._data) >= self._max_size and key not in self._data: self._evict_lru() - + # Set new data atomically self._data[key] = data self._timestamps[key] = current_time self._access_count[key] = 1 - + logger.debug(f"Cache set: key={key}, size={len(self._data)}") - + def invalidate(self, key: str = "default") -> None: """ Thread-safe invalidation of specific key. @@ -67,7 +67,7 @@ def invalidate(self, key: str = "default") -> None: with self._lock: self._remove_key(key) logger.debug(f"Cache invalidated: key={key}") - + def clear(self) -> None: """ Thread-safe clear all cache entries. @@ -77,7 +77,7 @@ def clear(self) -> None: self._timestamps.clear() self._access_count.clear() logger.debug("Cache cleared") - + def get_stats(self) -> dict: """ Get cache statistics for monitoring. @@ -85,17 +85,16 @@ def get_stats(self) -> dict: with self._lock: current_time = time.time() expired_count = sum( - 1 for ts in self._timestamps.values() - if current_time - ts >= self._ttl + 1 for ts in self._timestamps.values() if current_time - ts >= self._ttl ) - + return { "total_entries": len(self._data), "expired_entries": expired_count, "max_size": self._max_size, - "ttl_seconds": self._ttl + "ttl_seconds": self._ttl, } - + def _remove_key(self, key: str) -> None: """ Internal method to remove a key from all tracking dictionaries. @@ -104,7 +103,7 @@ def _remove_key(self, key: str) -> None: self._data.pop(key, None) self._timestamps.pop(key, None) self._access_count.pop(key, None) - + def _cleanup_expired(self) -> None: """ Internal method to clean up expired entries. @@ -112,16 +111,17 @@ def _cleanup_expired(self) -> None: """ current_time = time.time() expired_keys = [ - key for key, timestamp in self._timestamps.items() + key + for key, timestamp in self._timestamps.items() if current_time - timestamp >= self._ttl ] - + for key in expired_keys: self._remove_key(key) - + if expired_keys: logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries") - + def _evict_lru(self) -> None: """ Internal method to evict least recently used entry. @@ -129,29 +129,31 @@ def _evict_lru(self) -> None: """ if not self._access_count: return - + # Find key with lowest access count lru_key = min(self._access_count.keys(), key=lambda k: self._access_count[k]) self._remove_key(lru_key) logger.debug(f"Evicted LRU cache entry: {lru_key}") + class SimpleCache: """ Backward compatibility wrapper for existing code. """ - + def __init__(self, ttl: int = 60): self._cache = ThreadSafeCache(ttl=ttl, max_size=50) - + def get(self): return self._cache.get("default") - + def set(self, data): self._cache.set(data, "default") - + def invalidate(self): self._cache.invalidate("default") + # Global instances with improved configuration recent_issues_cache = ThreadSafeCache(ttl=300, max_size=20) # 5 minutes TTL, max 20 entries user_upload_cache = ThreadSafeCache(ttl=3600, max_size=1000) # 1 hour TTL for upload limits diff --git a/backend/config.py b/backend/config.py index 6703f830..418c8f3d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -5,7 +5,6 @@ import os import sys -from typing import Optional from dataclasses import dataclass from pathlib import Path @@ -16,27 +15,27 @@ class Config: Application configuration class with validation. Loads and validates all required environment variables. """ - + # API Keys (NVIDIA NIM preferred, Gemini as fallback) ai_api_key: str telegram_bot_token: str - + # Database database_url: str - + # Application Settings environment: str debug: bool cors_origins: list[str] - + # File Upload Settings max_upload_size_mb: int allowed_file_types: list[str] - + # Rate Limiting rate_limit_enabled: bool max_requests_per_minute: int - + @classmethod def from_env(cls) -> "Config": """ @@ -44,56 +43,51 @@ def from_env(cls) -> "Config": Raises ValueError if required variables are missing. """ errors = [] - + # Required variables — accept NVIDIA_API_KEY or GEMINI_API_KEY ai_api_key = os.getenv("NVIDIA_API_KEY") or os.getenv("GEMINI_API_KEY") if not ai_api_key: errors.append("NVIDIA_API_KEY or GEMINI_API_KEY is required") - + telegram_bot_token = os.getenv("TELEGRAM_BOT_TOKEN") if not telegram_bot_token: errors.append("TELEGRAM_BOT_TOKEN is required") - + # Database with default - database_url = os.getenv( - "DATABASE_URL", - "sqlite:///./data/issues.db" - ) - + database_url = os.getenv("DATABASE_URL", "sqlite:///./data/issues.db") + # Ensure data directory exists for SQLite if database_url.startswith("sqlite"): db_path = Path(database_url.replace("sqlite:///", "")) db_path.parent.mkdir(parents=True, exist_ok=True) - + # Optional settings with defaults environment = os.getenv("ENVIRONMENT", "development") debug = os.getenv("DEBUG", "false").lower() == "true" - + # CORS settings - cors_origins_str = os.getenv( - "CORS_ORIGINS", - "http://localhost:5173,http://localhost:3000" - ) + cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:3000") cors_origins = [origin.strip() for origin in cors_origins_str.split(",")] - + # File upload settings max_upload_size_mb = int(os.getenv("MAX_UPLOAD_SIZE_MB", "10")) allowed_file_types_str = os.getenv( - "ALLOWED_FILE_TYPES", - "image/jpeg,image/png,image/jpg,video/mp4" + "ALLOWED_FILE_TYPES", "image/jpeg,image/png,image/jpg,video/mp4" ) allowed_file_types = [ft.strip() for ft in allowed_file_types_str.split(",")] - + # Rate limiting rate_limit_enabled = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true" max_requests_per_minute = int(os.getenv("MAX_REQUESTS_PER_MINUTE", "60")) - + # If there are errors, raise with all missing variables if errors: - error_message = "Missing required environment variables:\n" + "\n".join(f" - {err}" for err in errors) + error_message = "Missing required environment variables:\n" + "\n".join( + f" - {err}" for err in errors + ) error_message += "\n\nPlease create a .env file with required variables. See .env.example for reference." raise ValueError(error_message) - + return cls( ai_api_key=ai_api_key, telegram_bot_token=telegram_bot_token, @@ -106,15 +100,15 @@ def from_env(cls) -> "Config": rate_limit_enabled=rate_limit_enabled, max_requests_per_minute=max_requests_per_minute, ) - + def is_production(self) -> bool: """Check if running in production environment.""" return self.environment.lower() == "production" - + def is_development(self) -> bool: """Check if running in development environment.""" return self.environment.lower() == "development" - + def get_database_type(self) -> str: """Get the type of database being used.""" if self.database_url.startswith("postgresql"): @@ -123,7 +117,7 @@ def get_database_type(self) -> str: return "sqlite" else: return "unknown" - + def validate_api_keys(self) -> dict[str, bool]: """ Validate that API keys are properly formatted. @@ -131,10 +125,11 @@ def validate_api_keys(self) -> dict[str, bool]: """ validations = { "ai_api_key": len(self.ai_api_key) > 20, - "telegram_bot_token": ":" in self.telegram_bot_token and len(self.telegram_bot_token) > 40, + "telegram_bot_token": ":" in self.telegram_bot_token + and len(self.telegram_bot_token) > 40, } return validations - + def __repr__(self) -> str: """Safe representation hiding sensitive data.""" return ( @@ -149,7 +144,7 @@ def __repr__(self) -> str: # Global config instance -_config: Optional[Config] = None +_config: Config | None = None def get_config() -> Config: @@ -174,26 +169,26 @@ def validate_startup_config() -> bool: """ try: config = get_config() - + print("\n✅ Configuration loaded successfully!") print(f" Environment: {config.environment}") print(f" Database: {config.get_database_type()}") print(f" Debug Mode: {config.debug}") - + # Validate API keys format validations = config.validate_api_keys() - + if not all(validations.values()): print("\n⚠️ Warning: Some API keys may be incorrectly formatted:") for key, is_valid in validations.items(): if not is_valid: print(f" - {key}: Invalid format") return False - + print(" API Keys: ✓ Valid format") print() return True - + except Exception as e: print(f"\n❌ Configuration validation failed: {e}\n", file=sys.stderr) return False diff --git a/backend/database.py b/backend/database.py index fb8cd6bc..cbb6443e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,27 +1,125 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, declarative_base +"""Database engine and session factory. + +DATABASE_URL is honoured when it works. When it does not, the service falls +back to local SQLite rather than failing every request. + +That fallback exists because of a real outage: the Postgres instance the +deployment pointed at was deleted, its hostname stopped resolving, and every +database-backed endpoint returned 500 while the service kept reporting itself +healthy. A civic reporting app that cannot accept a report is useless, and an +unreachable database is not a reason to refuse to run at all. + +The fallback is deliberately loud, never silent: + + * the failure is logged at ERROR with the reason, + * /health reports "sqlite-fallback" and the service reads "degraded", + * SQLITE_FALLBACK_ENABLED=false turns it off for deployments that would + rather fail hard than write somewhere unexpected. + +It is a stopgap, not a fix. On a platform with an ephemeral filesystem the +SQLite file does not survive a restart, so reports collected while degraded can +be lost. Repointing DATABASE_URL at a live database is the actual repair. +""" + +import logging import os -# Check for DATABASE_URL (Render/Postgres) or fall back to SQLite -SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL") +from sqlalchemy import create_engine, text +from sqlalchemy.orm import declarative_base, sessionmaker + +logger = logging.getLogger(__name__) + +SQLITE_URL = "sqlite:///./data/issues.db" +SQLITE_CONNECT_ARGS = {"check_same_thread": False} + +# How long to wait for the configured database before giving up on it. Short on +# purpose: this runs during startup, and a suspended platform instance is +# already slow to boot. +CONNECT_TIMEOUT_SECONDS = int(os.environ.get("DB_CONNECT_TIMEOUT", "10")) + +_FALLBACK_ENABLED = os.environ.get("SQLITE_FALLBACK_ENABLED", "true").lower() not in { + "0", + "false", + "no", +} + + +def _normalise(url: str) -> str: + """SQLAlchemy dropped the postgres:// alias; several hosts still emit it.""" + if url.startswith("postgres://"): + return url.replace("postgres://", "postgresql://", 1) + return url + -if SQLALCHEMY_DATABASE_URL and SQLALCHEMY_DATABASE_URL.startswith("postgres://"): - # Fix for SQLAlchemy requiring postgresql:// scheme - SQLALCHEMY_DATABASE_URL = SQLALCHEMY_DATABASE_URL.replace("postgres://", "postgresql://", 1) +def _connect_args_for(url: str) -> dict: + if url.startswith("sqlite"): + return dict(SQLITE_CONNECT_ARGS) + if url.startswith("postgresql"): + # Without this a dead host hangs the boot until the OS gives up. + return {"connect_timeout": CONNECT_TIMEOUT_SECONDS} + return {} -if not SQLALCHEMY_DATABASE_URL: - SQLALCHEMY_DATABASE_URL = "sqlite:///./data/issues.db" - connect_args = {"check_same_thread": False} -else: - connect_args = {} -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args=connect_args -) +def _is_reachable(candidate_engine) -> tuple[bool, str]: + try: + with candidate_engine.connect() as conn: + conn.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, f"{type(exc).__name__}: {exc}" + + +def _build_engine() -> tuple[object, str, bool]: + """Return (engine, url, using_fallback).""" + configured = os.environ.get("DATABASE_URL", "").strip() + + if not configured: + logger.info("DATABASE_URL is not set; using local SQLite at %s", SQLITE_URL) + return ( + create_engine(SQLITE_URL, connect_args=_connect_args_for(SQLITE_URL)), + SQLITE_URL, + False, + ) + + url = _normalise(configured) + candidate = create_engine(url, connect_args=_connect_args_for(url), pool_pre_ping=True) + + reachable, reason = _is_reachable(candidate) + if reachable: + return candidate, url, False + + if not _FALLBACK_ENABLED: + logger.error( + "Configured database is unreachable and SQLITE_FALLBACK_ENABLED is off. " + "Refusing to start against a database that does not answer. Reason: %s", + reason, + ) + raise RuntimeError(f"Configured database is unreachable: {reason}") + + logger.error( + "Configured database is unreachable, falling back to local SQLite. " + "Data written while degraded may not survive a restart on an ephemeral " + "filesystem. Repoint DATABASE_URL at a live database. Reason: %s", + reason, + ) + candidate.dispose() + return ( + create_engine(SQLITE_URL, connect_args=_connect_args_for(SQLITE_URL)), + SQLITE_URL, + True, + ) + + +# `data/` must exist before SQLite will create a file inside it. +os.makedirs("data", exist_ok=True) + +engine, SQLALCHEMY_DATABASE_URL, USING_SQLITE_FALLBACK = _build_engine() + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() + def get_db(): db = SessionLocal() try: diff --git a/backend/escalation_engine.py b/backend/escalation_engine.py index 67137b9b..18ec6adf 100644 --- a/backend/escalation_engine.py +++ b/backend/escalation_engine.py @@ -4,21 +4,34 @@ """ import datetime -from typing import List, Dict, Any, Optional +from typing import Any + +from sqlalchemy import and_ from sqlalchemy.orm import Session -from sqlalchemy import and_, or_ -from backend.models import Grievance, Jurisdiction, EscalationAudit, GrievanceStatus, JurisdictionLevel, EscalationReason, SeverityLevel + from backend.database import SessionLocal +from backend.models import ( + EscalationAudit, + EscalationReason, + Grievance, + GrievanceStatus, + SeverityLevel, +) from backend.routing_service import RoutingService from backend.sla_config_service import SLAConfigService + class EscalationEngine: """ Engine for handling grievance escalations based on SLA breaches and severity changes. """ - def __init__(self, routing_service: RoutingService, sla_service: SLAConfigService, - rules_config: Dict[str, Any]): + def __init__( + self, + routing_service: RoutingService, + sla_service: SLAConfigService, + rules_config: dict[str, Any], + ): """ Initialize the escalation engine. @@ -31,7 +44,7 @@ def __init__(self, routing_service: RoutingService, sla_service: SLAConfigServic self.sla_service = sla_service self.rules_config = rules_config - def evaluate_and_escalate_grievances(self, db: Session = None) -> Dict[str, int]: + def evaluate_and_escalate_grievances(self, db: Session = None) -> dict[str, int]: """ Evaluate all active grievances for escalation needs and perform escalations. @@ -57,17 +70,15 @@ def evaluate_and_escalate_grievances(self, db: Session = None) -> Dict[str, int] if success: escalated_count += 1 - return { - "evaluated": evaluated_count, - "escalated": escalated_count - } + return {"evaluated": evaluated_count, "escalated": escalated_count} finally: if db is not SessionLocal(): db.close() - def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityLevel, - reason: str = "", db: Session = None) -> bool: + def escalate_grievance_severity( + self, grievance_id: int, new_severity: SeverityLevel, reason: str = "", db: Session = None + ) -> bool: """ Escalate a grievance due to severity upgrade. @@ -91,14 +102,16 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL # Update severity old_severity = grievance.severity grievance.severity = new_severity - grievance.updated_at = datetime.datetime.now(datetime.timezone.utc) + grievance.updated_at = datetime.datetime.now(datetime.UTC) # Recalculate SLA self._recalculate_sla(grievance, db) # Check if escalation to higher jurisdiction is needed if self._should_escalate_due_to_severity(grievance, old_severity, db): - return self._escalate_grievance(grievance, EscalationReason.SEVERITY_UPGRADE, db, reason) + return self._escalate_grievance( + grievance, EscalationReason.SEVERITY_UPGRADE, db, reason + ) db.commit() return True @@ -137,7 +150,7 @@ def manual_escalate(self, grievance_id: int, reason: str = "", db: Session = Non if db is not SessionLocal(): db.close() - def _get_grievances_for_evaluation(self, db: Session) -> List[Grievance]: + def _get_grievances_for_evaluation(self, db: Session) -> list[Grievance]: """ Get grievances that should be evaluated for escalation. @@ -147,15 +160,25 @@ def _get_grievances_for_evaluation(self, db: Session) -> List[Grievance]: Returns: List of grievances to evaluate """ - now = datetime.datetime.now(datetime.timezone.utc) + now = datetime.datetime.now(datetime.UTC) # Get grievances that are active and past SLA deadline - return db.query(Grievance).filter( - and_( - Grievance.status.in_([GrievanceStatus.OPEN, GrievanceStatus.IN_PROGRESS, GrievanceStatus.ESCALATED]), - Grievance.sla_deadline < now + return ( + db.query(Grievance) + .filter( + and_( + Grievance.status.in_( + [ + GrievanceStatus.OPEN, + GrievanceStatus.IN_PROGRESS, + GrievanceStatus.ESCALATED, + ] + ), + Grievance.sla_deadline < now, + ) ) - ).all() + .all() + ) def _should_escalate(self, grievance: Grievance, db: Session) -> bool: """ @@ -169,14 +192,16 @@ def _should_escalate(self, grievance: Grievance, db: Session) -> bool: True if escalation is needed """ # Check if SLA is breached - now = datetime.datetime.now(datetime.timezone.utc) + now = datetime.datetime.now(datetime.UTC) if grievance.sla_deadline >= now: return False # Check if escalation is possible return self.routing_service.can_escalate(grievance.jurisdiction.level) - def _should_escalate_due_to_severity(self, grievance: Grievance, old_severity: SeverityLevel, db: Session) -> bool: + def _should_escalate_due_to_severity( + self, grievance: Grievance, old_severity: SeverityLevel, db: Session + ) -> bool: """ Check if severity change requires jurisdiction escalation. @@ -192,7 +217,7 @@ def _should_escalate_due_to_severity(self, grievance: Grievance, old_severity: S SeverityLevel.LOW: 1, SeverityLevel.MEDIUM: 2, SeverityLevel.HIGH: 3, - SeverityLevel.CRITICAL: 4 + SeverityLevel.CRITICAL: 4, } old_level = severity_hierarchy.get(old_severity, 1) @@ -204,8 +229,9 @@ def _should_escalate_due_to_severity(self, grievance: Grievance, old_severity: S return False - def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, - db: Session, notes: str = "") -> bool: + def _escalate_grievance( + self, grievance: Grievance, reason: EscalationReason, db: Session, notes: str = "" + ) -> bool: """ Perform the actual escalation of a grievance. @@ -220,7 +246,9 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, """ try: # Get next jurisdiction level - next_level = self.routing_service.get_next_jurisdiction_level(grievance.jurisdiction.level) + next_level = self.routing_service.get_next_jurisdiction_level( + grievance.jurisdiction.level + ) if not next_level: return False # Cannot escalate beyond national level @@ -230,7 +258,7 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, state=grievance.state, district=grievance.district, city=grievance.city, - db=db + db=db, ) if not new_jurisdiction: @@ -241,9 +269,11 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, # Update grievance grievance.current_jurisdiction_id = new_jurisdiction.id - grievance.assigned_authority = self.routing_service.assign_authority(new_jurisdiction, grievance.category) + grievance.assigned_authority = self.routing_service.assign_authority( + new_jurisdiction, grievance.category + ) grievance.status = GrievanceStatus.ESCALATED - grievance.updated_at = datetime.datetime.now(datetime.timezone.utc) + grievance.updated_at = datetime.datetime.now(datetime.UTC) # Recalculate SLA self._recalculate_sla(grievance, db) @@ -254,7 +284,7 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, previous_authority=previous_authority, new_authority=grievance.assigned_authority, reason=reason, - notes=notes + notes=notes, ) db.add(audit_log) @@ -279,8 +309,8 @@ def _recalculate_sla(self, grievance: Grievance, db: Session) -> None: severity=grievance.severity, jurisdiction_level=grievance.jurisdiction.level, department=grievance.category, - db=db + db=db, ) - now = datetime.datetime.now(datetime.timezone.utc) - grievance.sla_deadline = now + datetime.timedelta(hours=sla_hours) \ No newline at end of file + now = datetime.datetime.now(datetime.UTC) + grievance.sla_deadline = now + datetime.timedelta(hours=sla_hours) diff --git a/backend/exceptions.py b/backend/exceptions.py index 50ae6d1c..8ea8c9d8 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -2,20 +2,23 @@ Centralized exception handling for FastAPI application. Provides consistent error responses and logging. """ + import logging import traceback -from typing import Any, Dict, Optional -from fastapi import Request, HTTPException, status -from fastapi.responses import JSONResponse +from typing import Any + +import httpx +from fastapi import HTTPException, Request, status from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from pydantic import ValidationError -from sqlalchemy.exc import SQLAlchemyError, IntegrityError -import httpx +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from backend.schemas import ErrorResponse logger = logging.getLogger(__name__) + class VishwaGuruException(Exception): """Base exception for VishwaGuru application""" @@ -24,7 +27,7 @@ def __init__( message: str, error_code: str = "INTERNAL_ERROR", status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, - details: Optional[Dict[str, Any]] = None + details: dict[str, Any] | None = None, ): self.message = message self.error_code = error_code @@ -32,17 +35,19 @@ def __init__( self.details = details or {} super().__init__(self.message) + class ValidationException(VishwaGuruException): """Exception for validation errors""" - def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, message: str, details: dict[str, Any] | None = None): super().__init__( message=message, error_code="VALIDATION_ERROR", status_code=status.HTTP_400_BAD_REQUEST, - details=details + details=details, ) + class NotFoundException(VishwaGuruException): """Exception for resource not found""" @@ -54,75 +59,82 @@ def __init__(self, resource: str, resource_id: Any = None): message=message, error_code="NOT_FOUND", status_code=status.HTTP_404_NOT_FOUND, - details={"resource": resource, "resource_id": resource_id} + details={"resource": resource, "resource_id": resource_id}, ) + class ServiceUnavailableException(VishwaGuruException): """Exception for service unavailability""" - def __init__(self, service: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, service: str, details: dict[str, Any] | None = None): super().__init__( message=f"{service} service is temporarily unavailable", error_code="SERVICE_UNAVAILABLE", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - details=details or {"service": service} + details=details or {"service": service}, ) + class FileUploadException(VishwaGuruException): """Exception for file upload errors""" - def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, message: str, details: dict[str, Any] | None = None): super().__init__( message=message, error_code="FILE_UPLOAD_ERROR", status_code=status.HTTP_400_BAD_REQUEST, - details=details + details=details, ) + class AIServiceException(VishwaGuruException): """Exception for AI service errors""" - def __init__(self, message: str, service: str = "AI", details: Optional[Dict[str, Any]] = None): + def __init__(self, message: str, service: str = "AI", details: dict[str, Any] | None = None): super().__init__( message=message, error_code="AI_SERVICE_ERROR", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - details=details or {"service": service} + details=details or {"service": service}, ) + class ModelLoadException(VishwaGuruException): """Exception for ML model loading errors""" - def __init__(self, model_name: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, model_name: str, details: dict[str, Any] | None = None): super().__init__( message=f"Failed to load ML model: {model_name}", error_code="MODEL_LOAD_ERROR", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - details=details or {"model": model_name} + details=details or {"model": model_name}, ) + class DetectionException(VishwaGuruException): """Exception for image detection errors""" - def __init__(self, message: str, detection_type: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, message: str, detection_type: str, details: dict[str, Any] | None = None): super().__init__( message=message, error_code="DETECTION_ERROR", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - details=details or {"detection_type": detection_type} + details=details or {"detection_type": detection_type}, ) + class ExternalAPIException(VishwaGuruException): """Exception for external API failures""" - def __init__(self, api_name: str, message: str, details: Optional[Dict[str, Any]] = None): + def __init__(self, api_name: str, message: str, details: dict[str, Any] | None = None): super().__init__( message=message, error_code="EXTERNAL_API_ERROR", status_code=status.HTTP_502_BAD_GATEWAY, - details=details or {"api": api_name} + details=details or {"api": api_name}, ) + async def vishwaguru_exception_handler(request: Request, exc: VishwaGuruException) -> JSONResponse: """Handle VishwaGuru custom exceptions""" logger.error( @@ -132,19 +144,18 @@ async def vishwaguru_exception_handler(request: Request, exc: VishwaGuruExceptio "status_code": exc.status_code, "details": exc.details, "path": request.url.path, - "method": request.method - } + "method": request.method, + }, ) return JSONResponse( status_code=exc.status_code, content=ErrorResponse( - error=exc.message, - error_code=exc.error_code, - details=exc.details - ).model_dump(mode='json') + error=exc.message, error_code=exc.error_code, details=exc.details + ).model_dump(mode="json"), ) + async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: """Handle FastAPI HTTP exceptions""" logger.warning( @@ -153,8 +164,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe "status_code": exc.status_code, "detail": exc.detail, "path": request.url.path, - "method": request.method - } + "method": request.method, + }, ) return JSONResponse( @@ -162,19 +173,18 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe content=ErrorResponse( error=exc.detail, error_code=f"HTTP_{exc.status_code}", - details={"status_code": exc.status_code} - ).model_dump(mode='json') + details={"status_code": exc.status_code}, + ).model_dump(mode="json"), ) -async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: """Handle Pydantic validation errors""" logger.warning( f"ValidationError: {exc.errors()}", - extra={ - "errors": exc.errors(), - "path": request.url.path, - "method": request.method - } + extra={"errors": exc.errors(), "path": request.url.path, "method": request.method}, ) # Extract field-specific errors @@ -189,22 +199,18 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE content=ErrorResponse( error="Request validation failed", error_code="VALIDATION_ERROR", - details={ - "field_errors": field_errors, - "validation_errors": exc.errors() - } - ).model_dump(mode='json') + details={"field_errors": field_errors, "validation_errors": exc.errors()}, + ).model_dump(mode="json"), ) -async def pydantic_validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: + +async def pydantic_validation_exception_handler( + request: Request, exc: ValidationError +) -> JSONResponse: """Handle Pydantic ValidationError (different from RequestValidationError)""" logger.warning( f"Pydantic ValidationError: {exc.errors()}", - extra={ - "errors": exc.errors(), - "path": request.url.path, - "method": request.method - } + extra={"errors": exc.errors(), "path": request.url.path, "method": request.method}, ) return JSONResponse( @@ -212,10 +218,11 @@ async def pydantic_validation_exception_handler(request: Request, exc: Validatio content=ErrorResponse( error="Data validation failed", error_code="VALIDATION_ERROR", - details={"validation_errors": exc.errors()} - ).model_dump(mode='json') + details={"validation_errors": exc.errors()}, + ).model_dump(mode="json"), ) + async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse: """Handle SQLAlchemy database errors""" logger.error( @@ -224,8 +231,8 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) - extra={ "exception_type": type(exc).__name__, "path": request.url.path, - "method": request.method - } + "method": request.method, + }, ) # Handle specific SQLAlchemy errors @@ -235,8 +242,8 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) - content=ErrorResponse( error="Database constraint violation", error_code="DATABASE_CONSTRAINT_ERROR", - details={"constraint_error": str(exc)} - ).model_dump(mode='json') + details={"constraint_error": str(exc)}, + ).model_dump(mode="json"), ) return JSONResponse( @@ -244,10 +251,11 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) - content=ErrorResponse( error="Database operation failed", error_code="DATABASE_ERROR", - details={"db_error": str(exc)} - ).model_dump(mode='json') + details={"db_error": str(exc)}, + ).model_dump(mode="json"), ) + async def httpx_exception_handler(request: Request, exc: httpx.HTTPError) -> JSONResponse: """Handle HTTP client errors (external API calls)""" logger.error( @@ -256,8 +264,8 @@ async def httpx_exception_handler(request: Request, exc: httpx.HTTPError) -> JSO extra={ "exception_type": type(exc).__name__, "path": request.url.path, - "method": request.method - } + "method": request.method, + }, ) return JSONResponse( @@ -265,10 +273,11 @@ async def httpx_exception_handler(request: Request, exc: httpx.HTTPError) -> JSO content=ErrorResponse( error="External service communication failed", error_code="EXTERNAL_SERVICE_ERROR", - details={"http_error": str(exc)} - ).model_dump(mode='json') + details={"http_error": str(exc)}, + ).model_dump(mode="json"), ) + async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Handle any unhandled exceptions""" logger.error( @@ -278,8 +287,8 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes "exception_type": type(exc).__name__, "path": request.url.path, "method": request.method, - "traceback": traceback.format_exc() - } + "traceback": traceback.format_exc(), + }, ) return JSONResponse( @@ -287,10 +296,11 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes content=ErrorResponse( error="An unexpected error occurred", error_code="INTERNAL_SERVER_ERROR", - details={"exception_type": type(exc).__name__} - ).model_dump(mode='json') + details={"exception_type": type(exc).__name__}, + ).model_dump(mode="json"), ) + # Exception handlers mapping for easy registration EXCEPTION_HANDLERS = { VishwaGuruException: vishwaguru_exception_handler, @@ -300,4 +310,4 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes SQLAlchemyError: sqlalchemy_exception_handler, httpx.HTTPError: httpx_exception_handler, Exception: generic_exception_handler, -} \ No newline at end of file +} diff --git a/backend/flood_detection.py b/backend/flood_detection.py index 478a27ef..95eb7a86 100644 --- a/backend/flood_detection.py +++ b/backend/flood_detection.py @@ -1,7 +1,7 @@ -import io -import httpx from PIL import Image -from hf_service import detect_flooding_clip + +from backend.hf_service import detect_flooding_clip + async def detect_flooding(image: Image.Image): """ diff --git a/backend/flooding_detection.py b/backend/flooding_detection.py index 1df36046..3fea31fc 100644 --- a/backend/flooding_detection.py +++ b/backend/flooding_detection.py @@ -1,6 +1,8 @@ from PIL import Image + from backend.local_ml_service import detect_flooding_local + async def detect_flooding(image: Image.Image): """ Detects flooding in an image. diff --git a/backend/garbage_detection.py b/backend/garbage_detection.py index 2ec8807b..ba5ff0df 100644 --- a/backend/garbage_detection.py +++ b/backend/garbage_detection.py @@ -8,6 +8,7 @@ _model = None _model_lock = threading.Lock() + def load_model(): """ Loads the YOLO model lazily. @@ -15,14 +16,15 @@ def load_model(): logger.info("Loading Garbage Detection Model...") try: from ultralyticsplus import YOLO + # Using keremberke/yolov8n-garbage-segmentation as it follows the naming convention # of the existing pothole model (keremberke/yolov8n-pothole-segmentation). - model = YOLO('keremberke/yolov8n-garbage-segmentation') + model = YOLO("keremberke/yolov8n-garbage-segmentation") - model.overrides['conf'] = 0.25 - model.overrides['iou'] = 0.45 - model.overrides['agnostic_nms'] = False - model.overrides['max_det'] = 1000 + model.overrides["conf"] = 0.25 + model.overrides["iou"] = 0.45 + model.overrides["agnostic_nms"] = False + model.overrides["max_det"] = 1000 logger.info("Garbage Model loaded successfully.") return model @@ -30,6 +32,7 @@ def load_model(): logger.error(f"Failed to load garbage model: {e}") return None + def get_model(): global _model if _model is None: @@ -38,6 +41,19 @@ def get_model(): _model = load_model() return _model + +def reset_model(): + """Reset the model singleton. For tests only. + + Mirrors backend.pothole_detection.reset_model so both detection modules + expose the same testing seam. + """ + global _model + + with _model_lock: + _model = None + + def detect_garbage(image_source): """ Detects garbage in an image. @@ -56,22 +72,18 @@ def detect_garbage(image_source): # perform inference try: results = model.predict(image_source, stream=False) - result = results[0] # Single image + result = results[0] # Single image detections = [] - if hasattr(result, 'boxes'): - for i, box in enumerate(result.boxes): + if hasattr(result, "boxes"): + for box in result.boxes: coords = box.xyxy[0].cpu().numpy().tolist() conf = float(box.conf[0].cpu().numpy()) cls_id = int(box.cls[0].cpu().numpy()) label = result.names[cls_id] - detections.append({ - "box": coords, - "confidence": conf, - "label": label - }) + detections.append({"box": coords, "confidence": conf, "label": label}) return detections except Exception as e: diff --git a/backend/gemini_services.py b/backend/gemini_services.py index b54e1b19..35af02b8 100644 --- a/backend/gemini_services.py +++ b/backend/gemini_services.py @@ -1,15 +1,11 @@ """ Concrete implementations of AI service interfaces using Gemini AI. """ -from typing import Dict, Optional -import asyncio + from backend.ai_interfaces import ActionPlanService, ChatService, MLASummaryService -from backend.ai_service import ( - generate_action_plan as _generate_action_plan, - chat_with_civic_assistant as _chat_with_civic_assistant -) +from backend.ai_service import chat_with_civic_assistant as _chat_with_civic_assistant +from backend.ai_service import generate_action_plan as _generate_action_plan from backend.gemini_summary import generate_mla_summary as _generate_mla_summary -from backend.exceptions import AIServiceException class GeminiActionPlanService(ActionPlanService): @@ -19,12 +15,12 @@ async def generate_action_plan( self, issue_description: str, category: str, - language: str = 'en', - image_path: Optional[str] = None - ) -> Dict[str, str]: + language: str = "en", + image_path: str | None = None, + ) -> dict[str, str]: """ Generate action plan using Gemini AI. - + Raises: AIServiceException: If AI service fails """ @@ -37,7 +33,7 @@ class GeminiChatService(ChatService): async def chat(self, query: str) -> str: """ Process chat query using Gemini AI. - + Raises: AIServiceException: If AI service fails """ @@ -52,15 +48,17 @@ async def generate_mla_summary( district: str, assembly_constituency: str, mla_name: str, - issue_category: Optional[str] = None + issue_category: str | None = None, ) -> str: """ Generate MLA summary using Gemini AI. - + Raises: AIServiceException: If AI service fails """ - return await _generate_mla_summary(district, assembly_constituency, mla_name, issue_category) + return await _generate_mla_summary( + district, assembly_constituency, mla_name, issue_category + ) # Factory functions for easy service creation @@ -78,18 +76,22 @@ def create_gemini_mla_summary_service() -> GeminiMLASummaryService: """Create a Gemini-based MLA summary service.""" return GeminiMLASummaryService() + # Global service instance _ai_services = None + class AIServices: def __init__(self, action_plan_service, chat_service, mla_summary_service): self.action_plan_service = action_plan_service self.chat_service = chat_service self.mla_summary_service = mla_summary_service + def initialize_ai_services(action_plan_service, chat_service, mla_summary_service): global _ai_services _ai_services = AIServices(action_plan_service, chat_service, mla_summary_service) + def get_ai_services(): return _ai_services diff --git a/backend/gemini_summary.py b/backend/gemini_summary.py index 9efc29e1..8279eed7 100644 --- a/backend/gemini_summary.py +++ b/backend/gemini_summary.py @@ -5,9 +5,9 @@ summaries about MLAs and their roles. Includes retry logic with exponential backoff for handling transient failures. """ -import os + import logging -from typing import Optional +import os import httpx @@ -31,8 +31,10 @@ _API_BASE = None _MODEL_NAME = "gemini-1.5-flash" try: - import google.generativeai as genai import warnings + + import google.generativeai as genai + genai.configure(api_key=GEMINI_API_KEY) warnings.filterwarnings("ignore", category=FutureWarning, module="google.generativeai") except ImportError: @@ -80,10 +82,7 @@ async def _nvidia_chat(prompt: str) -> str: @exponential_backoff_retry(max_retries=3, base_delay=1.0, max_delay=10.0) async def _generate_mla_summary_with_retry( - district: str, - assembly_constituency: str, - mla_name: str, - issue_category: Optional[str] = None + district: str, assembly_constituency: str, mla_name: str, issue_category: str | None = None ) -> str: """Internal function that generates MLA summary with retry logic.""" issue_context = f" particularly regarding {issue_category} issues" if issue_category else "" @@ -100,7 +99,8 @@ async def _generate_mla_summary_with_retry( return await _nvidia_chat(prompt) elif _API_MODE == "gemini": import google.generativeai as genai - model = genai.GenerativeModel('gemini-1.5-flash') + + model = genai.GenerativeModel("gemini-1.5-flash") response = await model.generate_content_async(prompt) return response.text.strip() else: @@ -108,10 +108,7 @@ async def _generate_mla_summary_with_retry( async def generate_mla_summary( - district: str, - assembly_constituency: str, - mla_name: str, - issue_category: Optional[str] = None + district: str, assembly_constituency: str, mla_name: str, issue_category: str | None = None ) -> str: """ Generate a human-readable summary about an MLA using AI. diff --git a/backend/grievance_routes.py b/backend/grievance_routes.py new file mode 100644 index 00000000..6d00d5b9 --- /dev/null +++ b/backend/grievance_routes.py @@ -0,0 +1,188 @@ +"""HTTP routes for the grievance and escalation feature. + +backend/grievance_service.py, escalation_engine.py, routing_service.py and +sla_config_service.py were all implemented, and frontend/src/api/grievances.js +plus frontend/src/views/GrievanceView.jsx were written against them -- but no +router was ever mounted, so every one of these paths returned 404. This module +wires the existing service layer to the paths the frontend already calls. +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload + +from backend.auth import require_api_key +from backend.database import get_db +from backend.grievance_service import GrievanceService +from backend.models import Grievance, GrievanceStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["grievances"]) + +RULES_PATH = Path(__file__).resolve().parent / "grievance_rules.json" + +# Statuses that count as still open for the stats tile. +_ACTIVE_STATUSES = { + GrievanceStatus.OPEN, + getattr(GrievanceStatus, "IN_PROGRESS", GrievanceStatus.OPEN), + getattr(GrievanceStatus, "ESCALATED", GrievanceStatus.OPEN), +} + + +@lru_cache(maxsize=1) +def get_grievance_service() -> GrievanceService: + """One shared service instance. + + GrievanceService defaults its rules path to the relative string + "backend/grievance_rules.json", which only resolves when the process + happens to run from the repository root. An absolute path is passed here so + it works regardless of working directory. + """ + return GrievanceService(rules_config_path=str(RULES_PATH)) + + +def _enum_value(value: Any) -> Any: + return value.value if hasattr(value, "value") else value + + +def _serialise_escalation(audit) -> dict[str, Any]: + return { + "id": audit.id, + "grievance_id": audit.grievance_id, + "previous_authority": audit.previous_authority, + "new_authority": audit.new_authority, + "timestamp": audit.timestamp, + "reason": _enum_value(audit.reason), + "notes": audit.notes, + } + + +def _serialise_grievance(grievance: Grievance) -> dict[str, Any]: + """GrievanceView.jsx reads escalation_history unconditionally -- it calls + `.length` on it and maps over it -- so it is always present, never null.""" + return { + "id": grievance.id, + "unique_id": grievance.unique_id, + "category": grievance.category, + "severity": _enum_value(grievance.severity), + "status": _enum_value(grievance.status), + "pincode": grievance.pincode, + "city": grievance.city, + "district": grievance.district, + "state": grievance.state, + "latitude": grievance.latitude, + "longitude": grievance.longitude, + "address": grievance.address, + "assigned_authority": grievance.assigned_authority, + "sla_deadline": grievance.sla_deadline, + "created_at": grievance.created_at, + "updated_at": grievance.updated_at, + "resolved_at": grievance.resolved_at, + "escalation_history": [ + _serialise_escalation(audit) + for audit in sorted( + grievance.audit_logs or [], + key=lambda a: (a.timestamp is None, a.timestamp), + ) + ], + } + + +@router.get("/grievances") +def list_grievances( + status: str | None = Query(None), + category: str | None = Query(None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +): + query = db.query(Grievance).options(joinedload(Grievance.audit_logs)) + + if status: + try: + query = query.filter(Grievance.status == GrievanceStatus(status)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"Unknown status: {status}") from exc + if category: + query = query.filter(Grievance.category == category) + + rows = query.order_by(Grievance.created_at.desc()).offset(offset).limit(limit).all() + return [_serialise_grievance(row) for row in rows] + + +@router.get("/grievances/{grievance_id}") +def get_grievance(grievance_id: int, db: Session = Depends(get_db)): + grievance = ( + db.query(Grievance) + .options(joinedload(Grievance.audit_logs), joinedload(Grievance.jurisdiction)) + .filter(Grievance.id == grievance_id) + .first() + ) + if grievance is None: + raise HTTPException(status_code=404, detail="Grievance not found.") + return _serialise_grievance(grievance) + + +@router.get("/escalation-stats") +def escalation_stats(db: Session = Depends(get_db)): + """Tile values consumed by GrievanceView.jsx. + + escalation_rate is returned as a percentage because the view renders it as + `stats.escalation_rate.toFixed(1)%`. + """ + total = db.query(Grievance).count() + resolved = ( + db.query(Grievance).filter(Grievance.status == GrievanceStatus.RESOLVED).count() + if hasattr(GrievanceStatus, "RESOLVED") + else 0 + ) + escalated = db.query(Grievance.id).join(Grievance.audit_logs).distinct().count() + active = total - resolved + + return { + "total_grievances": total, + "escalated_grievances": escalated, + "active_grievances": active, + "resolved_grievances": resolved, + "escalation_rate": (escalated / total * 100) if total else 0.0, + } + + +@router.post("/grievances/{grievance_id}/escalate") +def escalate_grievance( + grievance_id: int, + reason: str = Query("", description="Why the grievance is being escalated"), + db: Session = Depends(get_db), + _api_key: str = Depends(require_api_key), +): + """Reassign a grievance to a higher authority. + + Requires X-API-Key. This changes which office is accountable for the + grievance and writes an audit record, so it must not be callable by anyone + who can reach the API. + """ + grievance = db.query(Grievance).filter(Grievance.id == grievance_id).first() + if grievance is None: + raise HTTPException(status_code=404, detail="Grievance not found.") + + try: + escalated = get_grievance_service().manual_escalate(grievance_id, reason) + except Exception as exc: + logger.exception("Manual escalation failed for grievance %s", grievance_id) + raise HTTPException(status_code=502, detail="Escalation service unavailable.") from exc + + if not escalated: + raise HTTPException( + status_code=409, + detail="Grievance could not be escalated; it may already be at the top level.", + ) + + db.refresh(grievance) + return {"status": "escalated", "grievance": _serialise_grievance(grievance)} diff --git a/backend/grievance_service.py b/backend/grievance_service.py index d0f40502..9b584824 100644 --- a/backend/grievance_service.py +++ b/backend/grievance_service.py @@ -3,20 +3,27 @@ Provides the main interface for grievance management and escalation. """ -import json -import uuid import hashlib +import json import threading -from typing import Dict, Any, Optional, List -from sqlalchemy.orm import Session, joinedload +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + from sqlalchemy import and_, desc -from datetime import datetime, timezone, timedelta +from sqlalchemy.orm import Session, joinedload -from backend.models import Grievance, Jurisdiction, GrievanceStatus, SeverityLevel, GrievanceFollower from backend.database import SessionLocal +from backend.escalation_engine import EscalationEngine +from backend.models import ( + Grievance, + GrievanceFollower, + GrievanceStatus, + SeverityLevel, +) from backend.routing_service import RoutingService from backend.sla_config_service import SLAConfigService -from backend.escalation_engine import EscalationEngine + class GrievanceService: """ @@ -35,20 +42,20 @@ def __init__(self, rules_config_path: str = "backend/grievance_rules.json"): Args: rules_config_path: Path to the rules configuration file """ - with open(rules_config_path, 'r') as f: + with open(rules_config_path) as f: self.rules_config = json.load(f) self.routing_service = RoutingService(self.rules_config) self.sla_service = SLAConfigService( - default_sla_hours=self.rules_config.get('sla_defaults', {}).get('default_hours', 48) + default_sla_hours=self.rules_config.get("sla_defaults", {}).get("default_hours", 48) ) self.escalation_engine = EscalationEngine( - self.routing_service, - self.sla_service, - self.rules_config + self.routing_service, self.sla_service, self.rules_config ) - def create_grievance(self, grievance_data: Dict[str, Any], db: Session = None) -> Optional[Grievance]: + def create_grievance( + self, grievance_data: dict[str, Any], db: Session = None + ) -> Grievance | None: """ Create a new grievance with automatic routing and SLA assignment. @@ -73,47 +80,46 @@ def create_grievance(self, grievance_data: Dict[str, Any], db: Session = None) - # Assign authority assigned_authority = self.routing_service.assign_authority( - jurisdiction, - grievance_data.get('category', 'general') + jurisdiction, grievance_data.get("category", "general") ) # Calculate SLA - severity = SeverityLevel(grievance_data.get('severity', 'medium')) + severity = SeverityLevel(grievance_data.get("severity", "medium")) sla_hours = self.sla_service.get_sla_hours( severity=severity, jurisdiction_level=jurisdiction.level, - department=grievance_data.get('category', 'general'), - db=db + department=grievance_data.get("category", "general"), + db=db, ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) sla_deadline = now + timedelta(hours=sla_hours) # Generate unique ID unique_id = str(uuid.uuid4())[:8].upper() # Extract location data - location_data = grievance_data.get('location', {}) - latitude = location_data.get('latitude') if isinstance(location_data, dict) else None - longitude = location_data.get('longitude') if isinstance(location_data, dict) else None - address = location_data.get('address') if isinstance(location_data, dict) else None + location_data = grievance_data.get("location", {}) + latitude = location_data.get("latitude") if isinstance(location_data, dict) else None + longitude = location_data.get("longitude") if isinstance(location_data, dict) else None + address = location_data.get("address") if isinstance(location_data, dict) else None # Create grievance grievance = Grievance( unique_id=unique_id, - category=grievance_data.get('category', 'general'), + category=grievance_data.get("category", "general"), severity=severity, - pincode=grievance_data.get('pincode'), - city=grievance_data.get('city'), - district=grievance_data.get('district'), - state=grievance_data.get('state'), + pincode=grievance_data.get("pincode"), + city=grievance_data.get("city"), + district=grievance_data.get("district"), + state=grievance_data.get("state"), latitude=latitude, longitude=longitude, address=address, current_jurisdiction_id=jurisdiction.id, assigned_authority=assigned_authority, sla_deadline=sla_deadline, - status=GrievanceStatus.OPEN + status=GrievanceStatus.OPEN, ) db.add(grievance) @@ -130,7 +136,9 @@ def create_grievance(self, grievance_data: Dict[str, Any], db: Session = None) - if is_local_session: db.close() - def follow_grievance(self, grievance_id: int, user_email: str, db: Session = None) -> Optional[GrievanceFollower]: + def follow_grievance( + self, grievance_id: int, user_email: str, db: Session = None + ) -> GrievanceFollower | None: """ Add a follower to a grievance with blockchain-style integrity hash. Optimized with O(1) hash cache to avoid expensive DB scans. @@ -142,12 +150,16 @@ def follow_grievance(self, grievance_id: int, user_email: str, db: Session = Non try: # Check if already following - existing = db.query(GrievanceFollower).filter( - and_( - GrievanceFollower.grievance_id == grievance_id, - GrievanceFollower.user_email == user_email + existing = ( + db.query(GrievanceFollower) + .filter( + and_( + GrievanceFollower.grievance_id == grievance_id, + GrievanceFollower.user_email == user_email, + ) ) - ).first() + .first() + ) if existing: return existing @@ -162,7 +174,7 @@ def follow_grievance(self, grievance_id: int, user_email: str, db: Session = Non grievance_id=grievance_id, user_email=user_email, integrity_hash=new_hash, - previous_integrity_hash=prev_hash + previous_integrity_hash=prev_hash, ) db.add(follower) @@ -183,7 +195,7 @@ def follow_grievance(self, grievance_id: int, user_email: str, db: Session = Non if is_local_session: db.close() - def _get_last_integrity_hash(self, grievance_id: int, db: Session) -> Optional[str]: + def _get_last_integrity_hash(self, grievance_id: int, db: Session) -> str | None: """ Retrieves the last integrity hash for a grievance. Bolt Optimization: Uses thread-safe memory cache for O(1) lookup. @@ -193,10 +205,12 @@ def _get_last_integrity_hash(self, grievance_id: int, db: Session) -> Optional[s return self._follower_last_hash_cache[grievance_id] # Cache miss: Fallback to indexed DB query - last_follower = db.query(GrievanceFollower)\ - .filter(GrievanceFollower.grievance_id == grievance_id)\ - .order_by(desc(GrievanceFollower.id))\ + last_follower = ( + db.query(GrievanceFollower) + .filter(GrievanceFollower.grievance_id == grievance_id) + .order_by(desc(GrievanceFollower.id)) .first() + ) last_hash = last_follower.integrity_hash if last_follower else None @@ -207,7 +221,7 @@ def _get_last_integrity_hash(self, grievance_id: int, db: Session) -> Optional[s return last_hash - def verify_follower_integrity(self, follower_id: int, db: Session = None) -> Dict[str, Any]: + def verify_follower_integrity(self, follower_id: int, db: Session = None) -> dict[str, Any]: """ Verify the blockchain-style integrity of a follower record. """ @@ -217,7 +231,9 @@ def verify_follower_integrity(self, follower_id: int, db: Session = None) -> Dic is_local_session = True try: - follower = db.query(GrievanceFollower).filter(GrievanceFollower.id == follower_id).first() + follower = ( + db.query(GrievanceFollower).filter(GrievanceFollower.id == follower_id).first() + ) if not follower: return {"is_valid": False, "message": "Follower record not found"} @@ -225,20 +241,20 @@ def verify_follower_integrity(self, follower_id: int, db: Session = None) -> Dic hash_input = f"{follower.grievance_id}|{follower.user_email}|{follower.previous_integrity_hash or 'GENESIS'}" calculated_hash = hashlib.sha256(hash_input.encode()).hexdigest() - is_valid = (calculated_hash == follower.integrity_hash) + is_valid = calculated_hash == follower.integrity_hash return { "is_valid": is_valid, "current_hash": follower.integrity_hash, "calculated_hash": calculated_hash, "previous_hash": follower.previous_integrity_hash, - "message": "Integrity verified" if is_valid else "INTEGRITY BREACH DETECTED" + "message": "Integrity verified" if is_valid else "INTEGRITY BREACH DETECTED", } finally: if is_local_session: db.close() - def get_grievance(self, grievance_id: int, db: Session = None) -> Optional[Grievance]: + def get_grievance(self, grievance_id: int, db: Session = None) -> Grievance | None: """ Get a grievance by ID. @@ -255,17 +271,20 @@ def get_grievance(self, grievance_id: int, db: Session = None) -> Optional[Griev is_local_session = True try: - return db.query(Grievance).options( - joinedload(Grievance.jurisdiction), - joinedload(Grievance.audit_logs) - ).filter(Grievance.id == grievance_id).first() + return ( + db.query(Grievance) + .options(joinedload(Grievance.jurisdiction), joinedload(Grievance.audit_logs)) + .filter(Grievance.id == grievance_id) + .first() + ) finally: if is_local_session: db.close() - def update_grievance_status(self, grievance_id: int, status: GrievanceStatus, - db: Session = None) -> bool: + def update_grievance_status( + self, grievance_id: int, status: GrievanceStatus, db: Session = None + ) -> bool: """ Update the status of a grievance. @@ -288,10 +307,10 @@ def update_grievance_status(self, grievance_id: int, status: GrievanceStatus, return False grievance.status = status - grievance.updated_at = datetime.now(timezone.utc) + grievance.updated_at = datetime.now(UTC) if status == GrievanceStatus.RESOLVED: - grievance.resolved_at = datetime.now(timezone.utc) + grievance.resolved_at = datetime.now(UTC) db.commit() return True @@ -304,8 +323,9 @@ def update_grievance_status(self, grievance_id: int, status: GrievanceStatus, if is_local_session: db.close() - def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityLevel, - reason: str = "") -> bool: + def escalate_grievance_severity( + self, grievance_id: int, new_severity: SeverityLevel, reason: str = "" + ) -> bool: """ Escalate grievance severity. @@ -317,7 +337,9 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL Returns: True if escalation successful """ - return self.escalation_engine.escalate_grievance_severity(grievance_id, new_severity, reason) + return self.escalation_engine.escalate_grievance_severity( + grievance_id, new_severity, reason + ) def manual_escalate(self, grievance_id: int, reason: str = "") -> bool: """ @@ -332,7 +354,7 @@ def manual_escalate(self, grievance_id: int, reason: str = "") -> bool: """ return self.escalation_engine.manual_escalate(grievance_id, reason) - def run_escalation_check(self) -> Dict[str, int]: + def run_escalation_check(self) -> dict[str, int]: """ Run periodic escalation evaluation for all grievances. @@ -341,7 +363,9 @@ def run_escalation_check(self) -> Dict[str, int]: """ return self.escalation_engine.evaluate_and_escalate_grievances() - def get_grievance_audit_trail(self, grievance_id: int, db: Session = None) -> List[Dict[str, Any]]: + def get_grievance_audit_trail( + self, grievance_id: int, db: Session = None + ) -> list[dict[str, Any]]: """ Get the complete audit trail for a grievance. @@ -364,13 +388,15 @@ def get_grievance_audit_trail(self, grievance_id: int, db: Session = None) -> Li audit_trail = [] for audit in grievance.audit_logs: - audit_trail.append({ - "timestamp": audit.timestamp.isoformat(), - "previous_authority": audit.previous_authority, - "new_authority": audit.new_authority, - "reason": audit.reason.value, - "notes": audit.notes - }) + audit_trail.append( + { + "timestamp": audit.timestamp.isoformat(), + "previous_authority": audit.previous_authority, + "new_authority": audit.new_authority, + "reason": audit.reason.value, + "notes": audit.notes, + } + ) return audit_trail @@ -378,7 +404,9 @@ def get_grievance_audit_trail(self, grievance_id: int, db: Session = None) -> Li if is_local_session: db.close() - def get_active_grievances_by_jurisdiction(self, jurisdiction_id: int, db: Session = None) -> List[Grievance]: + def get_active_grievances_by_jurisdiction( + self, jurisdiction_id: int, db: Session = None + ) -> list[Grievance]: """ Get active grievances for a specific jurisdiction. @@ -395,13 +423,23 @@ def get_active_grievances_by_jurisdiction(self, jurisdiction_id: int, db: Sessio is_local_session = True try: - return db.query(Grievance).filter( - and_( - Grievance.current_jurisdiction_id == jurisdiction_id, - Grievance.status.in_([GrievanceStatus.OPEN, GrievanceStatus.IN_PROGRESS, GrievanceStatus.ESCALATED]) + return ( + db.query(Grievance) + .filter( + and_( + Grievance.current_jurisdiction_id == jurisdiction_id, + Grievance.status.in_( + [ + GrievanceStatus.OPEN, + GrievanceStatus.IN_PROGRESS, + GrievanceStatus.ESCALATED, + ] + ), + ) ) - ).all() + .all() + ) finally: if is_local_session: - db.close() \ No newline at end of file + db.close() diff --git a/backend/hf_api_service.py b/backend/hf_api_service.py index 92c1718f..a3da7d57 100644 --- a/backend/hf_api_service.py +++ b/backend/hf_api_service.py @@ -1,10 +1,10 @@ -import os +import base64 import io +import logging +import os + import httpx -import base64 -from typing import Union, List, Dict, Any from PIL import Image -import logging logger = logging.getLogger(__name__) @@ -19,7 +19,9 @@ CAPTION_API_URL = "https://router.huggingface.co/models/Salesforce/blip-image-captioning-large" # Sentiment Analysis / Text Classification Model -SENTIMENT_API_URL = "https://router.huggingface.co/models/cardiffnlp/twitter-roberta-base-sentiment-latest" +SENTIMENT_API_URL = ( + "https://router.huggingface.co/models/cardiffnlp/twitter-roberta-base-sentiment-latest" +) # Visual Question Answering Model VQA_API_URL = "https://router.huggingface.co/models/dandelin/vilt-b32-finetuned-vqa" @@ -33,6 +35,7 @@ # Speech-to-Text Model (Whisper) WHISPER_API_URL = "https://router.huggingface.co/models/openai/whisper-large-v3-turbo" + async def _make_request(client, url, payload): try: response = await client.post(url, headers=headers, json=payload, timeout=20.0) @@ -44,23 +47,22 @@ async def _make_request(client, url, payload): logger.error(f"HF API Request Exception: {e}") return [] -def _prepare_image_bytes(image: Union[Image.Image, bytes]) -> bytes: + +def _prepare_image_bytes(image: Image.Image | bytes) -> bytes: if isinstance(image, bytes): return image img_byte_arr = io.BytesIO() - fmt = image.format if image.format else 'JPEG' + fmt = image.format if image.format else "JPEG" image.save(img_byte_arr, format=fmt) return img_byte_arr.getvalue() + async def query_hf_api(image_bytes, labels, client=None): """ Queries Hugging Face CLIP API for zero-shot image classification. """ - image_base64 = base64.b64encode(image_bytes).decode('utf-8') - payload = { - "inputs": image_base64, - "parameters": {"candidate_labels": labels} - } + image_base64 = base64.b64encode(image_bytes).decode("utf-8") + payload = {"inputs": image_base64, "parameters": {"candidate_labels": labels}} if client: return await _make_request(client, CLIP_API_URL, payload) @@ -68,85 +70,138 @@ async def query_hf_api(image_bytes, labels, client=None): async with httpx.AsyncClient() as new_client: return await _make_request(new_client, CLIP_API_URL, payload) -async def _detect_clip_generic(image: Union[Image.Image, bytes], labels: List[str], target_labels: List[str], client: httpx.AsyncClient = None): + +async def _detect_clip_generic( + image: Image.Image | bytes, + labels: list[str], + target_labels: list[str], + client: httpx.AsyncClient = None, +): try: img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) if not isinstance(results, list): - return [] + return [] detected = [] for res in results: - if isinstance(res, dict) and res.get('label') in target_labels and res.get('score', 0) > 0.4: - detected.append({ - "label": res['label'], - "confidence": res['score'], - "box": [] # CLIP doesn't provide boxes, but frontend expects this structure - }) + if ( + isinstance(res, dict) + and res.get("label") in target_labels + and res.get("score", 0) > 0.4 + ): + detected.append( + { + "label": res["label"], + "confidence": res["score"], + "box": [], # CLIP doesn't provide boxes, but frontend expects this structure + } + ) return detected except Exception as e: logger.error(f"HF Detection Error: {e}") return [] + # --- Specific Detectors --- -async def detect_pothole_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_pothole_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["pothole", "damaged road", "road crack", "smooth road", "clean street"] targets = ["pothole", "damaged road", "road crack"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_illegal_parking_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): - labels = ["illegal parking", "car blocking driveway", "double parked", "car on sidewalk", "legal parking", "empty street"] + +async def detect_illegal_parking_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): + labels = [ + "illegal parking", + "car blocking driveway", + "double parked", + "car on sidewalk", + "legal parking", + "empty street", + ] targets = ["illegal parking", "car blocking driveway", "double parked", "car on sidewalk"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_street_light_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): - labels = ["broken streetlight", "dark street", "street light off", "working streetlight", "daytime"] + +async def detect_street_light_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): + labels = [ + "broken streetlight", + "dark street", + "street light off", + "working streetlight", + "daytime", + ] targets = ["broken streetlight", "dark street", "street light off"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_fire_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_fire_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["fire", "smoke", "flames", "burning", "normal scene", "safe"] targets = ["fire", "smoke", "flames", "burning"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_stray_animal_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_stray_animal_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["stray dog", "stray cow", "cattle on road", "animal", "empty road"] targets = ["stray dog", "stray cow", "cattle on road", "animal"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_blocked_road_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_blocked_road_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["blocked road", "road debris", "construction block", "traffic jam", "clear road"] targets = ["blocked road", "road debris", "construction block"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_tree_hazard_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_tree_hazard_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["fallen tree", "broken branch", "hanging branch", "healthy tree", "no tree"] targets = ["fallen tree", "broken branch", "hanging branch"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_pest_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_pest_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["rat", "cockroach", "mosquito swarm", "pest infestation", "clean", "no pests"] targets = ["rat", "cockroach", "mosquito swarm", "pest infestation"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_water_leak_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_water_leak_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): labels = ["water leak", "burst pipe", "flooded floor", "puddle", "dry floor", "no water"] targets = ["water leak", "burst pipe", "flooded floor", "puddle"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_accessibility_issue_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): - labels = ["blocked wheelchair ramp", "stairs without ramp", "broken ramp", "accessible path", "wheelchair accessible", "clear path"] + +async def detect_accessibility_issue_clip( + image: Image.Image | bytes, client: httpx.AsyncClient = None +): + labels = [ + "blocked wheelchair ramp", + "stairs without ramp", + "broken ramp", + "accessible path", + "wheelchair accessible", + "clear path", + ] targets = ["blocked wheelchair ramp", "stairs without ramp", "broken ramp"] return await _detect_clip_generic(image, labels, targets, client) -async def detect_crowd_density_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): - labels = ["dense crowd", "dangerous overcrowding", "sparse crowd", "empty space", "safe crowd level"] + +async def detect_crowd_density_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): + labels = [ + "dense crowd", + "dangerous overcrowding", + "sparse crowd", + "empty space", + "safe crowd level", + ] # We want to detect high density targets = ["dense crowd", "dangerous overcrowding"] return await _detect_clip_generic(image, labels, targets, client) + async def detect_audio_event(audio_bytes: bytes, client: httpx.AsyncClient = None): """ Detects audio events from audio bytes using MIT/ast-finetuned-audioset-10-10-0.4593. @@ -154,8 +209,11 @@ async def detect_audio_event(audio_bytes: bytes, client: httpx.AsyncClient = Non # The Audio Classification API accepts raw audio bytes try: headers_bin = {"Authorization": f"Bearer {token}"} if token else {} + async def do_post(c): - return await c.post(AUDIO_CLASS_API_URL, headers=headers_bin, content=audio_bytes, timeout=30.0) + return await c.post( + AUDIO_CLASS_API_URL, headers=headers_bin, content=audio_bytes, timeout=30.0 + ) if client: response = await do_post(client) @@ -174,36 +232,53 @@ async def do_post(c): logger.error(f"Audio Detection Error: {e}") return [] -async def detect_severity_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_severity_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Returns a severity object: {level: 'High', confidence: 0.9, raw_label: 'critical...'} """ - labels = ["critical emergency", "high urgency", "medium urgency", "low urgency", "safe situation"] + labels = [ + "critical emergency", + "high urgency", + "medium urgency", + "low urgency", + "safe situation", + ] img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) if isinstance(results, list) and len(results) > 0: top = results[0] - label = top.get('label') - score = top.get('score', 0) + label = top.get("label") + score = top.get("score", 0) - level = "Low" - if label == "critical emergency": level = "Critical" - elif label == "high urgency": level = "High" - elif label == "medium urgency": level = "Medium" + level = { + "critical emergency": "Critical", + "high urgency": "High", + "medium urgency": "Medium", + }.get(label, "Low") return {"level": level, "confidence": score, "raw_label": label} return {"level": "Unknown", "confidence": 0, "raw_label": "unknown"} -async def detect_smart_scan_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_smart_scan_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Auto-detects category from image. """ labels = [ - "pothole", "garbage", "flooded street", "fire accident", - "fallen tree", "stray animal", "blocked road", "broken streetlight", - "illegal parking", "graffiti vandalism", "normal street" + "pothole", + "garbage", + "flooded street", + "fire accident", + "fallen tree", + "stray animal", + "blocked road", + "broken streetlight", + "illegal parking", + "graffiti vandalism", + "normal street", ] img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) @@ -212,28 +287,29 @@ async def detect_smart_scan_clip(image: Union[Image.Image, bytes], client: httpx top = results[0] # Map label to internal category ID if needed, or return raw return { - "category": top.get('label'), - "confidence": top.get('score'), - "all_scores": results[:3] + "category": top.get("label"), + "confidence": top.get("score"), + "all_scores": results[:3], } return {"category": "unknown", "confidence": 0} -async def generate_image_caption(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def generate_image_caption(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Generates a description using BLIP model. """ + # The image-to-text endpoint takes a binary body, so the request below posts + # img_bytes directly. A base64 payload dict used to be built here and then + # discarded, which read as though the request were JSON. img_bytes = _prepare_image_bytes(image) - image_base64 = base64.b64encode(img_bytes).decode('utf-8') - payload = {"inputs": image_base64} # BLIP API usually takes raw bytes or base64? - # Standard Inference API for image-to-text usually takes raw bytes body - - # NOTE: The standard Inference API for image-to-text (BLIP) accepts binary body. - # The _make_request helper assumes JSON. Let's handle this separately. try: headers_bin = {"Authorization": f"Bearer {token}"} if token else {} + async def do_post(c): - return await c.post(CAPTION_API_URL, headers=headers_bin, content=img_bytes, timeout=20.0) + return await c.post( + CAPTION_API_URL, headers=headers_bin, content=img_bytes, timeout=20.0 + ) if client: response = await do_post(client) @@ -245,9 +321,9 @@ async def do_post(c): # Result is usually [{"generated_text": "..."}] data = response.json() if isinstance(data, list) and len(data) > 0: - return data[0].get('generated_text', '') + return data[0].get("generated_text", "") if isinstance(data, dict): - return data.get('generated_text', '') + return data.get("generated_text", "") else: logger.error(f"Caption API Error: {response.status_code} - {response.text}") return "" @@ -256,12 +332,14 @@ async def do_post(c): return "" return "" + async def analyze_urgency_text(text: str, client: httpx.AsyncClient = None): """ Analyzes text urgency using Sentiment Analysis. Negative sentiment -> Higher Urgency. """ - if not text: return {"urgency": "Low", "score": 0} + if not text: + return {"urgency": "Low", "score": 0} payload = {"inputs": text} @@ -273,12 +351,12 @@ async def analyze_urgency_text(text: str, client: httpx.AsyncClient = None): # Result format: [[{'label': 'negative', 'score': 0.9}, ...]] (nested list) if isinstance(result, list) and len(result) > 0: - scores = result[0] # List of dicts + scores = result[0] # List of dicts if isinstance(scores, list): # Find label with highest score - top = max(scores, key=lambda x: x['score']) - label = top['label'] # 'positive', 'neutral', 'negative' - score = top['score'] + top = max(scores, key=lambda x: x["score"]) + label = top["label"] # 'positive', 'neutral', 'negative' + score = top["score"] urgency = "Low" if label == "negative": @@ -291,19 +369,16 @@ async def analyze_urgency_text(text: str, client: httpx.AsyncClient = None): return {"urgency": "Low", "score": 0, "sentiment": "unknown"} -async def verify_resolution_vqa(image: Union[Image.Image, bytes], question: str, client: httpx.AsyncClient = None): +async def verify_resolution_vqa( + image: Image.Image | bytes, question: str, client: httpx.AsyncClient = None +): """ Uses VQA to verify if an issue is resolved based on a question. """ img_bytes = _prepare_image_bytes(image) - image_base64 = base64.b64encode(img_bytes).decode('utf-8') + image_base64 = base64.b64encode(img_bytes).decode("utf-8") - payload = { - "inputs": { - "image": image_base64, - "question": question - } - } + payload = {"inputs": {"image": image_base64, "question": question}} if client: result = await _make_request(client, VQA_API_URL, payload) @@ -315,14 +390,15 @@ async def verify_resolution_vqa(image: Union[Image.Image, bytes], question: str, if isinstance(result, list) and len(result) > 0: top = result[0] return { - "answer": top.get('answer'), - "confidence": top.get('score'), - "all_answers": result[:3] + "answer": top.get("answer"), + "confidence": top.get("score"), + "all_answers": result[:3], } return {"answer": "unknown", "confidence": 0} -async def detect_depth_map(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_depth_map(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Generates a depth map for the given image using Intel/dpt-hybrid-midas. Returns a Base64 encoded string of the depth map image. @@ -332,8 +408,9 @@ async def detect_depth_map(image: Union[Image.Image, bytes], client: httpx.Async # The DPT model expects raw image bytes as input and returns raw image bytes (JPEG/PNG) try: headers_bin = {"Authorization": f"Bearer {token}"} if token else {} + async def do_post(c): - return await c.post(DEPTH_API_URL, headers=headers_bin, content=img_bytes, timeout=30.0) + return await c.post(DEPTH_API_URL, headers=headers_bin, content=img_bytes, timeout=30.0) if client: response = await do_post(client) @@ -345,7 +422,7 @@ async def do_post(c): # Response is a binary image response_bytes = response.content # Convert to base64 - b64_img = base64.b64encode(response_bytes).decode('utf-8') + b64_img = base64.b64encode(response_bytes).decode("utf-8") return {"depth_map": b64_img} else: logger.error(f"Depth API Error: {response.status_code} - {response.text}") @@ -355,14 +432,18 @@ async def do_post(c): logger.error(f"Depth Estimation Error: {e}") return {"error": str(e)} + async def transcribe_audio(audio_bytes: bytes, client: httpx.AsyncClient = None): """ Transcribes audio using OpenAI Whisper model via HF API. """ try: headers_bin = {"Authorization": f"Bearer {token}"} if token else {} + async def do_post(c): - return await c.post(WHISPER_API_URL, headers=headers_bin, content=audio_bytes, timeout=60.0) + return await c.post( + WHISPER_API_URL, headers=headers_bin, content=audio_bytes, timeout=60.0 + ) if client: response = await do_post(client) @@ -381,11 +462,20 @@ async def do_post(c): logger.error(f"Audio Transcription Error: {e}") return "" -async def detect_waste_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_waste_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Classifies waste type for sorting. """ - labels = ["plastic bottle", "glass bottle", "metal can", "paper cardboard", "organic food waste", "electronic waste", "general trash"] + labels = [ + "plastic bottle", + "glass bottle", + "metal can", + "paper cardboard", + "organic food waste", + "electronic waste", + "general trash", + ] img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) @@ -393,13 +483,14 @@ async def detect_waste_clip(image: Union[Image.Image, bytes], client: httpx.Asyn if isinstance(results, list) and len(results) > 0: top = results[0] return { - "waste_type": top.get('label'), - "confidence": top.get('score'), - "all_scores": results[:3] + "waste_type": top.get("label"), + "confidence": top.get("score"), + "all_scores": results[:3], } return {"waste_type": "unknown", "confidence": 0} -async def detect_civic_eye_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_civic_eye_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Performs a comprehensive assessment of the scene. """ @@ -410,7 +501,12 @@ async def detect_civic_eye_clip(image: Union[Image.Image, bytes], client: httpx. clean_labels = ["clean street", "dirty street", "garbage piled up", "spotless area"] # 3. Infrastructure - infra_labels = ["good infrastructure", "broken infrastructure", "potholes", "well maintained road"] + infra_labels = [ + "good infrastructure", + "broken infrastructure", + "potholes", + "well maintained road", + ] img_bytes = _prepare_image_bytes(image) @@ -423,7 +519,7 @@ async def detect_civic_eye_clip(image: Union[Image.Image, bytes], client: httpx. return {"error": "Analysis failed"} def get_top_category(res_list, category_labels): - relevant = [r for r in res_list if r.get('label') in category_labels] + relevant = [r for r in res_list if r.get("label") in category_labels] if relevant: return relevant[0] return {"label": "unknown", "score": 0} @@ -433,7 +529,7 @@ def get_top_category(res_list, category_labels): infra = get_top_category(results, infra_labels) return { - "safety": {"status": safety['label'], "score": safety['score']}, - "cleanliness": {"status": cleanliness['label'], "score": cleanliness['score']}, - "infrastructure": {"status": infra['label'], "score": infra['score']} + "safety": {"status": safety["label"], "score": safety["score"]}, + "cleanliness": {"status": cleanliness["label"], "score": cleanliness["score"]}, + "infrastructure": {"status": infra["label"], "score": infra["score"]}, } diff --git a/backend/hf_service.py b/backend/hf_service.py index 6b98c909..e7748f1b 100644 --- a/backend/hf_service.py +++ b/backend/hf_service.py @@ -4,16 +4,16 @@ This file is kept for reference purposes only. """ -import os + +import base64 import io +import logging +import os + import httpx -import base64 -from typing import Union, List, Dict, Any from PIL import Image -import asyncio -from retry_utils import exponential_backoff_retry -import logging -import base64 + +from backend.retry_utils import exponential_backoff_retry # Configure logging logger = logging.getLogger(__name__) @@ -22,7 +22,10 @@ token = os.environ.get("HF_TOKEN") headers = {"Authorization": f"Bearer {token}"} if token else {} API_URL = "https://api-inference.huggingface.co/models/openai/clip-vit-base-patch32" -CAPTION_API_URL = "https://api-inference.huggingface.co/models/Salesforce/blip-image-captioning-large" +CAPTION_API_URL = ( + "https://api-inference.huggingface.co/models/Salesforce/blip-image-captioning-large" +) + async def query_hf_api(image_bytes, labels, client=None): """ @@ -34,20 +37,16 @@ async def query_hf_api(image_bytes, labels, client=None): async with httpx.AsyncClient() as new_client: return await _make_request(new_client, image_bytes, labels) + @exponential_backoff_retry(max_retries=3, base_delay=1.0, max_delay=10.0) async def _make_request_with_retry(client, image_bytes, labels): """ Internal function that makes HF API request with retry logic. Raises exception on failure to allow retry decorator to work. """ - image_base64 = base64.b64encode(image_bytes).decode('utf-8') + image_base64 = base64.b64encode(image_bytes).decode("utf-8") - payload = { - "inputs": image_base64, - "parameters": { - "candidate_labels": labels - } - } + payload = {"inputs": image_base64, "parameters": {"candidate_labels": labels}} response = await client.post(API_URL, headers=headers, json=payload, timeout=20.0) if response.status_code != 200: @@ -67,43 +66,33 @@ async def _make_request(client, image_bytes, labels): logger.error(f"HF API Request failed after all retries: {e}", exc_info=True) return [] - payload = { - "inputs": image_base64, - "parameters": { - "candidate_labels": labels - } - } - - try: - response = await client.post(API_URL, headers=headers, json=payload, timeout=20.0) - if response.status_code != 200: - logger.error(f"HF API Error: {response.status_code} - {response.text}") - raise ExternalAPIException("Hugging Face API", f"HTTP {response.status_code}: {response.text}") - return response.json() - except httpx.HTTPError as e: - logger.error(f"HF API HTTP Error: {e}") - raise ExternalAPIException("Hugging Face API", str(e)) from e - except Exception as e: - logger.error(f"HF API Request Exception: {e}") - raise ExternalAPIException("Hugging Face API", str(e)) from e - -def _prepare_image_bytes(image: Union[Image.Image, bytes]) -> bytes: + +def _prepare_image_bytes(image: Image.Image | bytes) -> bytes: if isinstance(image, bytes): return image elif isinstance(image, Image.Image): img_byte_arr = io.BytesIO() - image.save(img_byte_arr, format='JPEG') + image.save(img_byte_arr, format="JPEG") return img_byte_arr.getvalue() else: raise ValueError("Unsupported image type") -async def detect_vandalism_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None): + +async def detect_vandalism_clip(image: Image.Image | bytes, client: httpx.AsyncClient = None): """ Detects vandalism/graffiti using Zero-Shot Image Classification with CLIP (Async). Includes retry logic with exponential backoff for transient failures. """ try: - labels = ["graffiti", "vandalism", "spray paint", "street art", "clean wall", "public property", "normal street"] + labels = [ + "graffiti", + "vandalism", + "spray paint", + "street art", + "clean wall", + "public property", + "normal street", + ] img_bytes = _prepare_image_bytes(image) @@ -111,78 +100,100 @@ async def detect_vandalism_clip(image: Union[Image.Image, bytes], client: httpx. # Results format: [{'label': 'graffiti', 'score': 0.9}, ...] if not isinstance(results, list): - return [] + return [] vandalism_labels = ["graffiti", "vandalism", "spray paint"] detected = [] for res in results: - if isinstance(res, dict) and res.get('label') in vandalism_labels and res.get('score', 0) > 0.4: - detected.append({ - "label": res['label'], - "confidence": res['score'], - "box": [] - }) + if ( + isinstance(res, dict) + and res.get("label") in vandalism_labels + and res.get("score", 0) > 0.4 + ): + detected.append({"label": res["label"], "confidence": res["score"], "box": []}) return detected except Exception as e: logger.error(f"HF Vandalism Detection Error: {e}", exc_info=True) return [] + async def detect_infrastructure_clip(image: Image.Image, client: httpx.AsyncClient = None): """ Detects infrastructure damage using Zero-Shot Image Classification with CLIP (Async). Includes retry logic with exponential backoff for transient failures. """ try: - labels = ["broken streetlight", "damaged traffic sign", "fallen tree", "damaged fence", "pothole", "clean street", "normal infrastructure"] + labels = [ + "broken streetlight", + "damaged traffic sign", + "fallen tree", + "damaged fence", + "pothole", + "clean street", + "normal infrastructure", + ] img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) if not isinstance(results, list): - return [] - - damage_labels = ["broken streetlight", "damaged traffic sign", "fallen tree", "damaged fence"] + return [] + + damage_labels = [ + "broken streetlight", + "damaged traffic sign", + "fallen tree", + "damaged fence", + ] detected = [] for res in results: - if isinstance(res, dict) and res.get('label') in damage_labels and res.get('score', 0) > 0.4: - detected.append({ - "label": res['label'], - "confidence": res['score'], - "box": [] - }) + if ( + isinstance(res, dict) + and res.get("label") in damage_labels + and res.get("score", 0) > 0.4 + ): + detected.append({"label": res["label"], "confidence": res["score"], "box": []}) return detected except Exception as e: logger.error(f"HF Infrastructure Detection Error: {e}", exc_info=True) return [] + async def detect_flooding_clip(image: Image.Image, client: httpx.AsyncClient = None): """ Detects flooding/waterlogging using Zero-Shot Image Classification with CLIP (Async). Includes retry logic with exponential backoff for transient failures. """ try: - labels = ["flooded street", "waterlogging", "blocked drain", "heavy rain", "dry street", "normal road"] + labels = [ + "flooded street", + "waterlogging", + "blocked drain", + "heavy rain", + "dry street", + "normal road", + ] img_bytes = _prepare_image_bytes(image) results = await query_hf_api(img_bytes, labels, client=client) if not isinstance(results, list): - return [] + return [] flooding_labels = ["flooded street", "waterlogging", "blocked drain", "heavy rain"] detected = [] for res in results: - if isinstance(res, dict) and res.get('label') in flooding_labels and res.get('score', 0) > 0.4: - detected.append({ - "label": res['label'], - "confidence": res['score'], - "box": [] - }) + if ( + isinstance(res, dict) + and res.get("label") in flooding_labels + and res.get("score", 0) > 0.4 + ): + detected.append({"label": res["label"], "confidence": res["score"], "box": []}) return detected except Exception as e: logger.error(f"HF Flooding Detection Error: {e}", exc_info=True) diff --git a/backend/image_validator.py b/backend/image_validator.py index 72a3dfd7..cb6bf389 100644 --- a/backend/image_validator.py +++ b/backend/image_validator.py @@ -4,14 +4,14 @@ """ import io -from typing import Tuple, Optional -from PIL import Image import logging +from PIL import Image + logger = logging.getLogger(__name__) # Configuration constants -SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'BMP', 'GIF'} +SUPPORTED_FORMATS = {"JPEG", "PNG", "WEBP", "BMP", "GIF"} MAX_IMAGE_WIDTH = 8000 # pixels MAX_IMAGE_HEIGHT = 8000 # pixels MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB in bytes @@ -21,19 +21,20 @@ class ImageValidationError(Exception): """Custom exception for image validation failures.""" + pass -def validate_image_file(file_bytes: bytes) -> Tuple[Image.Image, str]: +def validate_image_file(file_bytes: bytes) -> tuple[Image.Image, str]: """ Validate an image file for corruption, format support, and size constraints. - + Args: file_bytes: Raw bytes of the image file - + Returns: Tuple of (PIL Image object, format string) - + Raises: ImageValidationError: If validation fails with descriptive message """ @@ -41,19 +42,19 @@ def validate_image_file(file_bytes: bytes) -> Tuple[Image.Image, str]: file_size = len(file_bytes) if file_size == 0: raise ImageValidationError("Image file is empty") - + if file_size > MAX_FILE_SIZE: raise ImageValidationError( - f"Image file size ({file_size / (1024*1024):.2f}MB) exceeds maximum allowed size ({MAX_FILE_SIZE / (1024*1024):.0f}MB)" + f"Image file size ({file_size / (1024 * 1024):.2f}MB) exceeds maximum allowed size ({MAX_FILE_SIZE / (1024 * 1024):.0f}MB)" ) - + # Try to open the image try: image = Image.open(io.BytesIO(file_bytes)) except Exception as e: logger.error(f"Failed to open image: {e}") - raise ImageValidationError(f"Invalid or corrupted image file: {str(e)}") - + raise ImageValidationError(f"Invalid or corrupted image file: {str(e)}") from e + # Verify image integrity using PIL's verify method try: # Create a copy for verification since verify() can consume the file @@ -61,45 +62,47 @@ def validate_image_file(file_bytes: bytes) -> Tuple[Image.Image, str]: verify_image.verify() except Exception as e: logger.error(f"Image verification failed: {e}") - raise ImageValidationError(f"Image file is corrupted or invalid: {str(e)}") - + raise ImageValidationError(f"Image file is corrupted or invalid: {str(e)}") from e + # Re-open the image after verify (verify consumes the image data) image = Image.open(io.BytesIO(file_bytes)) - + # Check format support if image.format not in SUPPORTED_FORMATS: raise ImageValidationError( f"Unsupported image format: {image.format}. Supported formats: {', '.join(SUPPORTED_FORMATS)}" ) - + # Check image dimensions width, height = image.size - + if width < MIN_IMAGE_WIDTH or height < MIN_IMAGE_HEIGHT: raise ImageValidationError( f"Image dimensions ({width}x{height}) are too small. Minimum size: {MIN_IMAGE_WIDTH}x{MIN_IMAGE_HEIGHT}" ) - + if width > MAX_IMAGE_WIDTH or height > MAX_IMAGE_HEIGHT: raise ImageValidationError( f"Image dimensions ({width}x{height}) exceed maximum allowed size ({MAX_IMAGE_WIDTH}x{MAX_IMAGE_HEIGHT})" ) - - logger.info(f"Image validated successfully: format={image.format}, size={width}x{height}, file_size={file_size/1024:.2f}KB") - + + logger.info( + f"Image validated successfully: format={image.format}, size={width}x{height}, file_size={file_size / 1024:.2f}KB" + ) + return image, image.format -async def validate_uploaded_image(file_obj) -> Tuple[Image.Image, str]: +async def validate_uploaded_image(file_obj) -> tuple[Image.Image, str]: """ Validate an uploaded image file (FastAPI UploadFile). - + Args: file_obj: FastAPI UploadFile object - + Returns: Tuple of (PIL Image object, format string) - + Raises: ImageValidationError: If validation fails """ @@ -108,11 +111,11 @@ async def validate_uploaded_image(file_obj) -> Tuple[Image.Image, str]: file_bytes = await file_obj.read() # Reset file pointer for potential re-reading await file_obj.seek(0) - + return validate_image_file(file_bytes) except ImageValidationError: # Re-raise validation errors as-is raise except Exception as e: logger.error(f"Unexpected error during image upload validation: {e}") - raise ImageValidationError(f"Failed to process uploaded image: {str(e)}") + raise ImageValidationError(f"Failed to process uploaded image: {str(e)}") from e diff --git a/backend/infrastructure_detection.py b/backend/infrastructure_detection.py index a58379a2..91032145 100644 --- a/backend/infrastructure_detection.py +++ b/backend/infrastructure_detection.py @@ -1,6 +1,8 @@ -from backend.local_ml_service import detect_infrastructure_local from PIL import Image +from backend.local_ml_service import detect_infrastructure_local + + async def detect_infrastructure(image: Image.Image): """ Wrapper for infrastructure damage detection using Local ML Service. diff --git a/backend/init_db.py b/backend/init_db.py deleted file mode 100644 index 2c6b9450..00000000 --- a/backend/init_db.py +++ /dev/null @@ -1,178 +0,0 @@ -from sqlalchemy import text -from backend.database import engine -import logging - -logger = logging.getLogger(__name__) - -def migrate_db(): - """ - Perform database migrations. - This is a simple MVP migration strategy. - """ - try: - with engine.connect() as conn: - # Check for upvotes column and add if missing - try: - # SQLite doesn't support IF NOT EXISTS in ALTER TABLE - # So we just try to add it and ignore error if it exists - conn.execute(text("ALTER TABLE issues ADD COLUMN upvotes INTEGER DEFAULT 0")) - logger.info("Migrated database: Added upvotes column.") - except Exception: - pass - - # Check if index exists or create it - try: - conn.execute(text("CREATE INDEX ix_issues_upvotes ON issues (upvotes)")) - logger.info("Migrated database: Added index on upvotes column.") - except Exception: - pass - - # Add index on created_at for faster sorting - try: - conn.execute(text("CREATE INDEX ix_issues_created_at ON issues (created_at)")) - logger.info("Migrated database: Added index on created_at column.") - except Exception: - pass - - # Add index on status for faster filtering - try: - conn.execute(text("CREATE INDEX ix_issues_status ON issues (status)")) - logger.info("Migrated database: Added index on status column.") - except Exception: - pass - - # --- New Migrations --- - - # Add action_plan column - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN action_plan TEXT")) - print("Migrated database: Added action_plan column.") - except Exception: - pass - - # Add index on user_email - try: - conn.execute(text("CREATE INDEX ix_issues_user_email ON issues (user_email)")) - print("Migrated database: Added index on user_email column.") - except Exception: - pass - - # Add index on source - try: - conn.execute(text("CREATE INDEX ix_issues_source ON issues (source)")) - print("Migrated database: Added index on source column.") - except Exception: - pass - - # Add latitude column - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN latitude FLOAT")) - print("Migrated database: Added latitude column.") - except Exception: - pass - - # Add longitude column - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN longitude FLOAT")) - print("Migrated database: Added longitude column.") - except Exception: - pass - - # Add index on latitude for faster spatial queries - try: - conn.execute(text("CREATE INDEX ix_issues_latitude ON issues (latitude)")) - logger.info("Migrated database: Added index on latitude column.") - except Exception: - # Index likely already exists - pass - - # Add index on longitude for faster spatial queries - try: - conn.execute(text("CREATE INDEX ix_issues_longitude ON issues (longitude)")) - logger.info("Migrated database: Added index on longitude column.") - except Exception: - # Index likely already exists - pass - - # Add composite index for optimized spatial+status queries - try: - conn.execute(text("CREATE INDEX ix_issues_status_lat_lon ON issues (status, latitude, longitude)")) - logger.info("Migrated database: Added composite index on status, latitude, longitude.") - except Exception: - # Index likely already exists - pass - - # Add location column - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN location VARCHAR")) - print("Migrated database: Added location column.") - except Exception: - pass - - # Add action_plan column - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN action_plan TEXT")) - print("Migrated database: Added action_plan column.") - except Exception: - pass - - # Add index on user_email - try: - conn.execute(text("CREATE INDEX ix_issues_user_email ON issues (user_email)")) - logger.info("Migrated database: Added index on user_email column.") - except Exception: - # Index likely already exists - pass - - # --- Grievance Migrations --- - # Add latitude column to grievances - try: - conn.execute(text("ALTER TABLE grievances ADD COLUMN latitude FLOAT")) - logger.info("Migrated database: Added latitude column to grievances.") - except Exception: - pass - - # Add longitude column to grievances - try: - conn.execute(text("ALTER TABLE grievances ADD COLUMN longitude FLOAT")) - logger.info("Migrated database: Added longitude column to grievances.") - except Exception: - pass - - # Add address column to grievances - try: - conn.execute(text("ALTER TABLE grievances ADD COLUMN address VARCHAR")) - logger.info("Migrated database: Added address column to grievances.") - except Exception: - pass - - # Add index on latitude (grievances) - try: - conn.execute(text("CREATE INDEX ix_grievances_latitude ON grievances (latitude)")) - except Exception: - pass - - # Add index on longitude (grievances) - try: - conn.execute(text("CREATE INDEX ix_grievances_longitude ON grievances (longitude)")) - except Exception: - pass - - # Add composite index for spatial+status (grievances) - try: - conn.execute(text("CREATE INDEX ix_grievances_status_lat_lon ON grievances (status, latitude, longitude)")) - logger.info("Migrated database: Added composite index on status, latitude, longitude for grievances.") - except Exception: - pass - - # Add composite index for status+jurisdiction (grievances) - try: - conn.execute(text("CREATE INDEX ix_grievances_status_jurisdiction ON grievances (status, current_jurisdiction_id)")) - logger.info("Migrated database: Added composite index on status, jurisdiction for grievances.") - except Exception: - pass - - conn.commit() - logger.info("Database migration check completed.") - except Exception as e: - logger.error(f"Database migration error: {e}") diff --git a/backend/init_grievance_system.py b/backend/init_grievance_system.py index 572b74f9..27a26c8e 100644 --- a/backend/init_grievance_system.py +++ b/backend/init_grievance_system.py @@ -4,9 +4,9 @@ """ from backend.database import SessionLocal, engine -from backend.models import Jurisdiction, JurisdictionLevel, SLAConfig, SeverityLevel from backend.grievance_service import GrievanceService -import json +from backend.models import Jurisdiction, JurisdictionLevel, SeverityLevel, SLAConfig + def initialize_grievance_system(): """ @@ -14,6 +14,7 @@ def initialize_grievance_system(): """ # Create tables from backend.models import Base + Base.metadata.create_all(bind=engine) db = SessionLocal() @@ -25,34 +26,38 @@ def initialize_grievance_system(): "level": JurisdictionLevel.LOCAL, "geographic_coverage": {"cities": ["Mumbai"], "districts": ["Mumbai"]}, "responsible_authority": "Mumbai Municipal Corporation", - "default_sla_hours": 24 + "default_sla_hours": 24, }, { "level": JurisdictionLevel.DISTRICT, "geographic_coverage": {"districts": ["Mumbai", "Pune"], "states": ["Maharashtra"]}, "responsible_authority": "Maharashtra District Administration", - "default_sla_hours": 48 + "default_sla_hours": 48, }, { "level": JurisdictionLevel.STATE, "geographic_coverage": {"states": ["Maharashtra"]}, "responsible_authority": "Maharashtra State Government", - "default_sla_hours": 72 + "default_sla_hours": 72, }, { "level": JurisdictionLevel.NATIONAL, "geographic_coverage": {"states": ["Maharashtra", "Karnataka", "Delhi"]}, "responsible_authority": "Government of India", - "default_sla_hours": 168 # 1 week - } + "default_sla_hours": 168, # 1 week + }, ] for jur_data in jurisdictions_data: # Check if jurisdiction already exists - existing = db.query(Jurisdiction).filter( - Jurisdiction.level == jur_data["level"], - Jurisdiction.responsible_authority == jur_data["responsible_authority"] - ).first() + existing = ( + db.query(Jurisdiction) + .filter( + Jurisdiction.level == jur_data["level"], + Jurisdiction.responsible_authority == jur_data["responsible_authority"], + ) + .first() + ) if not existing: jurisdiction = Jurisdiction(**jur_data) @@ -65,40 +70,46 @@ def initialize_grievance_system(): "severity": SeverityLevel.CRITICAL, "jurisdiction_level": JurisdictionLevel.LOCAL, "department": "health", - "sla_hours": 4 + "sla_hours": 4, }, { "severity": SeverityLevel.HIGH, "jurisdiction_level": JurisdictionLevel.DISTRICT, "department": "police", - "sla_hours": 12 + "sla_hours": 12, }, { "severity": SeverityLevel.MEDIUM, "jurisdiction_level": JurisdictionLevel.STATE, "department": "education", - "sla_hours": 48 + "sla_hours": 48, }, { "severity": SeverityLevel.LOW, "jurisdiction_level": JurisdictionLevel.NATIONAL, "department": "infrastructure", - "sla_hours": 168 - } + "sla_hours": 168, + }, ] for sla_data in sla_configs_data: # Check if SLA config already exists - existing = db.query(SLAConfig).filter( - SLAConfig.severity == sla_data["severity"], - SLAConfig.jurisdiction_level == sla_data["jurisdiction_level"], - SLAConfig.department == sla_data["department"] - ).first() + existing = ( + db.query(SLAConfig) + .filter( + SLAConfig.severity == sla_data["severity"], + SLAConfig.jurisdiction_level == sla_data["jurisdiction_level"], + SLAConfig.department == sla_data["department"], + ) + .first() + ) if not existing: sla_config = SLAConfig(**sla_data) db.add(sla_config) - print(f"Created SLA config: {sla_data['severity'].value} - {sla_data['department']} - {sla_data['sla_hours']}h") + print( + f"Created SLA config: {sla_data['severity'].value} - {sla_data['department']} - {sla_data['sla_hours']}h" + ) db.commit() print("Grievance system initialized successfully!") @@ -109,6 +120,7 @@ def initialize_grievance_system(): finally: db.close() + def test_grievance_creation(): """ Test the grievance creation and escalation system. @@ -123,7 +135,7 @@ def test_grievance_creation(): "city": "Mumbai", "district": "Mumbai", "state": "Maharashtra", - "description": "Emergency medical facility needed" + "description": "Emergency medical facility needed", }, { "category": "police", @@ -131,27 +143,30 @@ def test_grievance_creation(): "city": "Pune", "district": "Pune", "state": "Maharashtra", - "description": "Security concern in public area" + "description": "Security concern in public area", }, { "category": "education", "severity": "medium", "district": "Mumbai", "state": "Maharashtra", - "description": "School infrastructure issue" - } + "description": "School infrastructure issue", + }, ] print("\nTesting grievance creation:") for i, grievance_data in enumerate(test_grievances, 1): grievance = service.create_grievance(grievance_data) if grievance: - print(f"✓ Created grievance {i}: {grievance.unique_id} - {grievance.category} - {grievance.assigned_authority}") + print( + f"✓ Created grievance {i}: {grievance.unique_id} - {grievance.category} - {grievance.assigned_authority}" + ) else: print(f"✗ Failed to create grievance {i}") + if __name__ == "__main__": print("Initializing Grievance Escalation System...") initialize_grievance_system() test_grievance_creation() - print("\nGrievance system setup complete!") \ No newline at end of file + print("\nGrievance system setup complete!") diff --git a/backend/local_ml_service.py b/backend/local_ml_service.py index 23208513..822e8715 100644 --- a/backend/local_ml_service.py +++ b/backend/local_ml_service.py @@ -5,10 +5,12 @@ and flooding detection using YOLO models, eliminating the dependency on Hugging Face API. """ + import logging -from PIL import Image import threading + from fastapi.concurrency import run_in_threadpool +from PIL import Image from backend.exceptions import DetectionException @@ -33,33 +35,35 @@ def load_general_model(): try: import torch from ultralytics import YOLO - + # Monkey-patch torch.load to use weights_only=False for YOLO model loading # This is safe because YOLO models from ultralytics are from a trusted source original_load = torch.load + def patched_load(*args, **kwargs): - kwargs['weights_only'] = False + kwargs["weights_only"] = False return original_load(*args, **kwargs) + torch.load = patched_load - + try: # Using YOLOv8 nano model for general object detection (lighter weight) # This model can detect 80+ common objects which we can use for # vandalism, infrastructure, and flooding detection - model = YOLO('yolov8n.pt') - + model = YOLO("yolov8n.pt") + # Configure model parameters - model.overrides['conf'] = 0.25 - model.overrides['iou'] = 0.45 - model.overrides['agnostic_nms'] = False - model.overrides['max_det'] = 1000 - + model.overrides["conf"] = 0.25 + model.overrides["iou"] = 0.45 + model.overrides["agnostic_nms"] = False + model.overrides["max_det"] = 1000 + logger.info("General Object Detection Model loaded successfully.") return model finally: # Restore original torch.load torch.load = original_load - + except Exception as e: logger.error(f"Failed to load general detection model: {e}") return None @@ -78,14 +82,14 @@ def get_general_model(): async def detect_vandalism_local(image: Image.Image, client=None): """ Detects vandalism/graffiti using local YOLO model (Async compatible). - + This uses a general object detection model and interprets results in the context of vandalism detection. It looks for suspicious objects or scene anomalies. - + Args: image: PIL Image object client: Unused parameter for compatibility with HF service - + Returns: List of detections with label, confidence, and box coordinates """ @@ -94,56 +98,60 @@ async def detect_vandalism_local(image: Image.Image, client=None): if not model: logger.warning("Detection model not available, returning empty detections.") return [] - + # Run model prediction in threadpool to avoid blocking event loop results = await run_in_threadpool(model.predict, image, stream=False) result = results[0] - + detections = [] - - if hasattr(result, 'boxes'): + + if hasattr(result, "boxes"): for box in result.boxes: coords = box.xyxy[0].cpu().numpy().tolist() conf = float(box.conf[0].cpu().numpy()) cls_id = int(box.cls[0].cpu().numpy()) label = result.names[cls_id] - + # For vandalism, we flag detections with reasonable confidence # This is a heuristic approach - in production, you'd want a specialized model if conf > 0.4: # Map generic labels to vandalism context vandalism_label = "potential vandalism" - if label.lower() in ['person', 'bottle']: + if label.lower() in ["person", "bottle"]: vandalism_label = "vandalism activity" - - detections.append({ - "label": vandalism_label, - "confidence": conf * HEURISTIC_CONFIDENCE_FACTOR, - "box": coords - }) - + + detections.append( + { + "label": vandalism_label, + "confidence": conf * HEURISTIC_CONFIDENCE_FACTOR, + "box": coords, + } + ) + # If we detect multiple suspicious objects, mark it as vandalism if len(detections) > 0: logger.info(f"Vandalism detection found {len(detections)} suspicious objects") - + return detections - + except Exception as e: logger.error(f"Local Vandalism Detection Error: {e}") - raise DetectionException("Failed to detect vandalism", "vandalism", details={"error": str(e)}) from e + raise DetectionException( + "Failed to detect vandalism", "vandalism", details={"error": str(e)} + ) from e async def detect_infrastructure_local(image: Image.Image, client=None): """ Detects infrastructure damage using local YOLO model (Async compatible). - + This uses a general object detection model and interprets results in the context of infrastructure damage. It looks for objects that might indicate damage. - + Args: image: PIL Image object client: Unused parameter for compatibility with HF service - + Returns: List of detections with label, confidence, and box coordinates """ @@ -152,57 +160,68 @@ async def detect_infrastructure_local(image: Image.Image, client=None): if not model: logger.warning("Detection model not available, returning empty detections.") return [] - + # Run model prediction in threadpool to avoid blocking event loop results = await run_in_threadpool(model.predict, image, stream=False) result = results[0] - + detections = [] - + # Objects that might indicate infrastructure issues - infrastructure_related = ['car', 'truck', 'traffic light', 'stop sign', 'bench', 'fire hydrant'] - - if hasattr(result, 'boxes'): + infrastructure_related = [ + "car", + "truck", + "traffic light", + "stop sign", + "bench", + "fire hydrant", + ] + + if hasattr(result, "boxes"): for box in result.boxes: coords = box.xyxy[0].cpu().numpy().tolist() conf = float(box.conf[0].cpu().numpy()) cls_id = int(box.cls[0].cpu().numpy()) label = result.names[cls_id] - + # Flag infrastructure-related objects if conf > 0.4 and label.lower() in infrastructure_related: # Map to infrastructure context infra_label = "infrastructure object" - if label.lower() in ['traffic light', 'stop sign']: + if label.lower() in ["traffic light", "stop sign"]: infra_label = "damaged sign" - elif label.lower() == 'fire hydrant': + elif label.lower() == "fire hydrant": infra_label = "damaged hydrant" - - detections.append({ - "label": infra_label, - "confidence": conf * HEURISTIC_CONFIDENCE_FACTOR, - "box": coords - }) - + + detections.append( + { + "label": infra_label, + "confidence": conf * HEURISTIC_CONFIDENCE_FACTOR, + "box": coords, + } + ) + logger.info(f"Infrastructure detection found {len(detections)} objects") return detections - + except Exception as e: logger.error(f"Local Infrastructure Detection Error: {e}") - raise DetectionException("Failed to detect infrastructure damage", "infrastructure", details={"error": str(e)}) from e + raise DetectionException( + "Failed to detect infrastructure damage", "infrastructure", details={"error": str(e)} + ) from e async def detect_flooding_local(image: Image.Image, client=None): """ Detects flooding using local YOLO model (Async compatible). - + This uses a general object detection model and interprets results in the context of flooding. It looks for objects that might be partially submerged or water-related. - + Args: image: PIL Image object client: Unused parameter for compatibility with HF service - + Returns: List of detections with label, confidence, and box coordinates """ @@ -211,48 +230,50 @@ async def detect_flooding_local(image: Image.Image, client=None): if not model: logger.warning("Detection model not available, returning empty detections.") return [] - + # Run model prediction in threadpool to avoid blocking event loop results = await run_in_threadpool(model.predict, image, stream=False) result = results[0] - + detections = [] - + # Objects that might be affected by flooding - flooding_indicators = ['car', 'truck', 'person', 'bicycle', 'motorcycle', 'bench'] - - if hasattr(result, 'boxes'): + flooding_indicators = ["car", "truck", "person", "bicycle", "motorcycle", "bench"] + + if hasattr(result, "boxes"): for box in result.boxes: coords = box.xyxy[0].cpu().numpy().tolist() conf = float(box.conf[0].cpu().numpy()) cls_id = int(box.cls[0].cpu().numpy()) label = result.names[cls_id] - + # Check if objects are in positions that might indicate flooding if conf > 0.4 and label.lower() in flooding_indicators: # Heuristic: if bottom of bounding box is below image center, # it might be partially submerged - image_height = image.height if hasattr(image, 'height') else 480 + image_height = image.height if hasattr(image, "height") else 480 box_bottom = coords[3] - + if box_bottom > image_height * 0.6: - detections.append({ - "label": "potential flooding", - "confidence": conf * LOW_CONFIDENCE_FACTOR, - "box": coords - }) - + detections.append( + { + "label": "potential flooding", + "confidence": conf * LOW_CONFIDENCE_FACTOR, + "box": coords, + } + ) + logger.info(f"Flooding detection found {len(detections)} indicators") return detections - + except Exception as e: logger.error(f"Local Flooding Detection Error: {e}") - raise DetectionException("Failed to detect flooding", "flooding", details={"error": str(e)}) from e + raise DetectionException( + "Failed to detect flooding", "flooding", details={"error": str(e)} + ) from e + async def get_detection_status(): """Get status of local detection model.""" model = get_general_model() - return { - "model_loaded": model is not None, - "backend": "local_yolo" - } + return {"model_loaded": model is not None, "backend": "local_yolo"} diff --git a/backend/maharashtra_locator.py b/backend/maharashtra_locator.py index d69e6a7c..315b95e2 100644 --- a/backend/maharashtra_locator.py +++ b/backend/maharashtra_locator.py @@ -4,10 +4,11 @@ Provides functions to lookup constituency and MLA information based on pincode for Maharashtra state. """ + import json import os from functools import lru_cache -from typing import Optional, Dict, Any +from typing import Any # District Pincode Ranges (Fallback data) # Format: (start, end, district) @@ -46,50 +47,43 @@ (441601, 441911, "Gondia"), (442605, 442709, "Gadchiroli"), (444105, 444512, "Washim"), - (443001, 443403, "Buldhana") + (443001, 443403, "Buldhana"), ] + @lru_cache(maxsize=1) -def load_maharashtra_pincode_data() -> Dict[str, Dict[str, Any]]: +def load_maharashtra_pincode_data() -> dict[str, dict[str, Any]]: """ Load and cache Maharashtra pincode to constituency mapping data. - + Returns: dict: Dictionary mapping pincode to data """ - file_path = os.path.join( - os.path.dirname(__file__), - "data", - "mh_pincode_sample.json" - ) - - with open(file_path, "r", encoding="utf-8") as f: + file_path = os.path.join(os.path.dirname(__file__), "data", "mh_pincode_sample.json") + + with open(file_path, encoding="utf-8") as f: data_list = json.load(f) # Convert list to dictionary for O(1) lookup return {item["pincode"]: item for item in data_list} @lru_cache(maxsize=1) -def load_maharashtra_mla_data() -> Dict[str, Dict[str, Any]]: +def load_maharashtra_mla_data() -> dict[str, dict[str, Any]]: """ Load and cache Maharashtra MLA information data. - + Returns: dict: Dictionary mapping constituency to MLA data """ - file_path = os.path.join( - os.path.dirname(__file__), - "data", - "mh_mla_sample.json" - ) - - with open(file_path, "r", encoding="utf-8") as f: + file_path = os.path.join(os.path.dirname(__file__), "data", "mh_mla_sample.json") + + with open(file_path, encoding="utf-8") as f: data_list = json.load(f) # Convert list to dictionary for O(1) lookup return {item["assembly_constituency"]: item for item in data_list} -def get_district_by_pincode_range(pincode: int) -> Optional[str]: +def get_district_by_pincode_range(pincode: int) -> str | None: """ Find district by checking pincode ranges. This is an O(N) fallback where N is number of ranges (~35). @@ -101,74 +95,77 @@ def get_district_by_pincode_range(pincode: int) -> Optional[str]: @lru_cache(maxsize=1) -def _load_maharashtra_pincode_map() -> Dict[str, Dict[str, Any]]: +def _load_maharashtra_pincode_map() -> dict[str, dict[str, Any]]: """ Load and cache Maharashtra pincode to constituency mapping as a dict for O(1) lookup. """ - pincode_data = load_maharashtra_pincode_data() - return {entry["pincode"]: entry for entry in pincode_data if "pincode" in entry} + # load_maharashtra_pincode_data() already returns {pincode: entry}. The + # previous body re-keyed it as though it were still a list, and iterating a + # dict yields its string keys -- so `entry["pincode"]` never matched and this + # returned an empty map. Every constituency and MLA lookup answered None. + return load_maharashtra_pincode_data() @lru_cache(maxsize=1) -def _load_maharashtra_mla_map() -> Dict[str, Dict[str, Any]]: +def _load_maharashtra_mla_map() -> dict[str, dict[str, Any]]: """ Load and cache Maharashtra MLA information as a dict for O(1) lookup. """ - mla_data = load_maharashtra_mla_data() - return {entry["assembly_constituency"]: entry for entry in mla_data if "assembly_constituency" in entry} + # Same double-conversion bug as the pincode map above. + return load_maharashtra_mla_data() -def find_constituency_by_pincode(pincode: str) -> Optional[Dict[str, Any]]: +def find_constituency_by_pincode(pincode: str) -> dict[str, Any] | None: """ Find constituency information by pincode. - + Args: pincode: 6-digit pincode string - + Returns: Dictionary with district, state, and assembly_constituency or None if not found """ if not pincode or len(pincode) != 6 or not pincode.isdigit(): return None - + # Use O(1) map lookup instead of O(n) list iteration pincode_map = _load_maharashtra_pincode_map() entry = pincode_map.get(pincode) - + if entry: return { "district": entry.get("district"), "state": entry.get("state"), - "assembly_constituency": entry.get("assembly_constituency") + "assembly_constituency": entry.get("assembly_constituency"), } - + return None -def find_mla_by_constituency(constituency_name: str) -> Optional[Dict[str, Any]]: +def find_mla_by_constituency(constituency_name: str) -> dict[str, Any] | None: """ Find MLA information by assembly constituency name. - + Args: constituency_name: Name of the assembly constituency - + Returns: Dictionary with mla_name, party, phone, email or None if not found """ if not constituency_name: return None - + # Use O(1) map lookup instead of O(n) list iteration mla_map = _load_maharashtra_mla_map() entry = mla_map.get(constituency_name) - + if entry: return { "mla_name": entry.get("mla_name"), "party": entry.get("party"), "phone": entry.get("phone"), "email": entry.get("email"), - "twitter": entry.get("twitter") + "twitter": entry.get("twitter"), } - + return None diff --git a/backend/main.py b/backend/main.py index c34808e2..d33933ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,71 +1,180 @@ -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query -from fastapi.middleware.cors import CORSMiddleware -from fastapi.concurrency import run_in_threadpool -from sqlalchemy.orm import Session -from database import engine, get_db -from models import Base, Issue -from ai_service import generate_action_plan, chat_with_civic_assistant -from maharashtra_locator import find_constituency_by_pincode, find_mla_by_constituency -from pydantic import BaseModel -from gemini_summary import generate_mla_summary +"""VishwaGuru API - FastAPI application entrypoint. + +Import policy: every intra-project import uses the fully qualified `backend.` +package path. Mixing bare (`from models import ...`) and packaged +(`from backend.models import ...`) forms loaded the same module twice under two +names, which registered every SQLAlchemy table twice on one MetaData and made +`backend.main` raise InvalidRequestError at import time -- the app could not +start at all. +""" + +import inspect +import io import json +import logging import os -import io +import shutil import sys +import uuid +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from functools import lru_cache -# Add the project root to sys.path so we can import 'backend' modules -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from fastapi import FastAPI, HTTPException, UploadFile, File, Form, BackgroundTasks, Depends, Query +import httpx +from fastapi import ( + BackgroundTasks, + Depends, + FastAPI, + File, + Form, + HTTPException, + Query, + Request, + Response, + UploadFile, + status, +) +from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.concurrency import run_in_threadpool +from PIL import Image from pydantic import BaseModel +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address +from sqlalchemy import func, text from sqlalchemy.orm import Session -from database import SessionLocal, engine, Base -from schemas import SuccessResponse, HealthResponse, StatsResponse, MLStatusResponse -from models import Issue -from contextlib import asynccontextmanager -import shutil -import datetime -from sqlalchemy import text -from typing import Optional, List -from functools import lru_cache -import PIL.Image -import uuid -import logging + +from backend.ai_factory import create_all_ai_services +from backend.ai_interfaces import get_ai_services, initialize_ai_services +from backend.ai_service import ( + analyze_issue_with_ai, + chat_with_civic_assistant, + generate_action_plan, +) +from backend.auth import require_api_key +from backend.bot import ( + application, # noqa: F401 - re-exported for callers + start_bot_thread, + stop_bot_thread, +) +from backend.cache import recent_issues_cache +from backend.database import USING_SQLITE_FALLBACK, SessionLocal, engine +from backend.flood_detection import detect_flooding +from backend.garbage_detection import detect_garbage +from backend.hf_api_service import ( + analyze_urgency_text, + detect_accessibility_issue_clip, + detect_audio_event, + detect_blocked_road_clip, + detect_civic_eye_clip, + detect_crowd_density_clip, + detect_depth_map, + detect_fire_clip, + detect_illegal_parking_clip, + detect_pest_clip, + detect_severity_clip, + detect_smart_scan_clip, + detect_stray_animal_clip, + detect_street_light_clip, + detect_tree_hazard_clip, + detect_waste_clip, + detect_water_leak_clip, + generate_image_caption, + transcribe_audio, + verify_resolution_vqa, +) +from backend.image_validator import ( + MAX_IMAGE_HEIGHT, + MAX_IMAGE_WIDTH, + validate_image_file, +) +from backend.local_ml_service import detect_infrastructure_local +from backend.maharashtra_locator import ( + DISTRICT_RANGES, + find_constituency_by_pincode, + find_mla_by_constituency, + load_maharashtra_mla_data, + load_maharashtra_pincode_data, +) +from backend.models import Issue +from backend.pothole_detection import detect_potholes +from backend.schemas import ( + HealthResponse, + MLStatusResponse, + StatsResponse, + SuccessResponse, +) +from backend.spatial_utils import find_nearby_issues +from backend.unified_detection_service import ( + detect_infrastructure as detect_infrastructure_unified, +) +from backend.unified_detection_service import ( + detect_vandalism as detect_vandalism_unified, +) +from backend.unified_detection_service import ( + get_detection_status, +) +from backend.vandalism_detection import detect_vandalism logger = logging.getLogger(__name__) -# Import specialized detection modules -from pothole_detection import detect_potholes -from garbage_detection import detect_garbage -from vandalism_detection import detect_vandalism -from flood_detection import detect_flooding +# Only the process with this set runs the Telegram poller; see the lifespan. +RUN_TELEGRAM_BOT = os.getenv("RUN_TELEGRAM_BOT", "").lower() in {"1", "true", "yes"} -# Import AI and Logic services -from ai_service import analyze_issue_image, chat_with_civic_assistant, analyze_issue_with_ai, generate_action_plan -from maharashtra_locator import get_district_by_pincode_range, find_constituency_by_pincode, find_mla_by_constituency, load_maharashtra_pincode_data, load_maharashtra_mla_data -from responsibility_mapper import get_responsible_authority -from bot import application # Import the Telegram Application -from gemini_summary import generate_mla_summary +# Schema creation deliberately does NOT happen here. +# +# Base.metadata.create_all(bind=engine) used to run at import time, which meant +# an unreachable database took the whole process down before it could serve a +# single request -- including /health, so the platform could not even report +# what was wrong. It also created tables implicitly and unversioned, which is +# precisely what Alembic now owns. +# +# Migrations run as part of the start command (see render.yaml). A deploy that +# cannot migrate fails visibly instead of serving 500s against a schema that was +# never created. -# Create the database tables -Base.metadata.create_all(bind=engine) @asynccontextmanager async def lifespan(app: FastAPI): # --- Startup --- - print("Starting up backend...") + logger.info("Starting up backend...") - # Initialize the Telegram bot + # One shared httpx client for all outbound model calls. Opening a client + # per request exhausts sockets under load and loses connection reuse. + app.state.http_client = httpx.AsyncClient(timeout=30.0) + + # Initialize the AI service container. get_ai_services() raises + # RuntimeError until this runs, which made /api/mh/rep-contacts fail. try: - await application.initialize() - await application.updater.start_polling() - await application.start() - print("Telegram bot started.") - except Exception as e: - print(f"Error starting Telegram bot: {e}") + action_plan_service, chat_service, mla_summary_service = create_all_ai_services() + initialize_ai_services( + action_plan_service=action_plan_service, + chat_service=chat_service, + mla_summary_service=mla_summary_service, + ) + logger.info("AI services initialized.") + except Exception: + logger.exception("Failed to initialize AI services") + + # Telegram bot. + # + # Polling used to run inside this lifespan, which meant every uvicorn worker + # opened its own long-poll against Telegram. Telegram rejects the extras + # with HTTP 409, so the API could never be scaled past a single worker. + # + # The poller now runs on its own thread and only in the process that opts in + # via RUN_TELEGRAM_BOT. Run the web service with it unset and one dedicated + # worker with it set, and the API scales horizontally. + if RUN_TELEGRAM_BOT: + if start_bot_thread() is None: + logger.warning( + "RUN_TELEGRAM_BOT is set but the bot did not start; " + "TELEGRAM_BOT_TOKEN is probably missing." + ) + else: + logger.info("RUN_TELEGRAM_BOT is not set; this process serves the API only.") # Preload data try: @@ -75,47 +184,136 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error pre-loading Maharashtra data: {e}") - # Run database migrations - try: - with engine.connect() as conn: - try: - conn.execute(text("CREATE INDEX ix_issues_created_at ON issues (created_at)")) - except Exception: pass - try: - conn.execute(text("CREATE INDEX ix_issues_status ON issues (status)")) - except Exception: pass - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN upvotes INTEGER DEFAULT 0")) - except Exception: pass - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN user_email VARCHAR")) - except Exception: pass - conn.commit() - except Exception as e: - print(f"Migration warning: {e}") + # Schema migrations are NOT run here. + # + # This block used to execute raw ALTER/CREATE INDEX statements on every + # startup, tolerating each failure individually because the column or index + # usually already existed. That has no ordering, no down path, and no record + # of which revision a database is on, and with more than one worker every + # process raced to apply it. + # + # Alembic owns the schema now. Migrations run once per deploy, before the + # service starts -- see preDeployCommand in render.yaml -- so a worker that + # boots can assume the schema is already correct: + # + # alembic upgrade head yield + # --- Shutdown --- - print("Shutting down backend...") try: - await application.updater.stop() - await application.stop() - await application.shutdown() - print("Telegram bot stopped.") - except Exception as e: - print(f"Error stopping Telegram bot: {e}") + await app.state.http_client.aclose() + except Exception: + logger.exception("Error closing HTTP client") + + logger.info("Shutting down backend...") + if RUN_TELEGRAM_BOT: + try: + await run_in_threadpool(stop_bot_thread) + except Exception: + logger.exception("Error stopping the Telegram bot thread") + + +# Rate limiting. +# +# RATE_LIMIT_ENABLED and MAX_REQUESTS_PER_MINUTE were declared in render.yaml +# and parsed in config.py, but nothing ever enforced them, so every endpoint was +# unmetered. That matters here beyond the usual denial-of-service concern: the +# detector and chat endpoints call paid inference APIs on every request, so an +# unmetered endpoint is a billing exposure as much as an availability one. +# +# AI-backed routes get a tighter bucket than plain reads. Storage is in-process, +# which is correct for a single service instance; a multi-instance deployment +# needs a shared backend (RATE_LIMIT_STORAGE_URI, e.g. redis://...). +RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true" +MAX_REQUESTS_PER_MINUTE = int(os.getenv("MAX_REQUESTS_PER_MINUTE", "60")) +AI_REQUESTS_PER_MINUTE = int(os.getenv("AI_REQUESTS_PER_MINUTE", "12")) +RATE_LIMIT_STORAGE_URI = os.getenv("RATE_LIMIT_STORAGE_URI", "memory://") + +DEFAULT_RATE_LIMIT = f"{MAX_REQUESTS_PER_MINUTE}/minute" +AI_RATE_LIMIT = f"{AI_REQUESTS_PER_MINUTE}/minute" + +limiter = Limiter( + key_func=get_remote_address, + default_limits=[DEFAULT_RATE_LIMIT] if RATE_LIMIT_ENABLED else [], + storage_uri=RATE_LIMIT_STORAGE_URI, + enabled=RATE_LIMIT_ENABLED, +) app = FastAPI(lifespan=lifespan) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) +logger.info( + "Rate limiting %s (default %s, AI %s)", + "enabled" if RATE_LIMIT_ENABLED else "disabled", + DEFAULT_RATE_LIMIT, + AI_RATE_LIMIT, +) + +# CORS. +# +# The previous configuration paired allow_origins=["*"] with +# allow_credentials=True. That combination is invalid per the Fetch spec -- +# browsers reject a wildcard Access-Control-Allow-Origin on a credentialed +# request -- and it also ignored the CORS_ORIGINS variable that render.yaml +# already declares. Origins are now read from the environment, with a +# localhost-only default so a misconfigured deploy fails closed rather than +# open. +# A Capacitor WebView does not serve the app from the site's domain: on Android +# it is https://localhost, on iOS capacitor://localhost. Those origins will +# never appear in a CORS_ORIGINS value written for the web deployment, so +# without them every request from the packaged app is blocked by the WebView's +# CORS check even when the URL is correct. They are appended to whatever the +# environment configures rather than replacing it. +MOBILE_APP_ORIGINS = [ + "https://localhost", + "capacitor://localhost", + "ionic://localhost", +] + +LOCAL_DEV_ORIGINS = [ + "http://localhost:5173", + "http://localhost:4173", + "http://127.0.0.1:5173", +] + + +def _allowed_origins() -> list[str]: + raw = os.getenv("CORS_ORIGINS", "").strip() + if raw: + configured = [o.strip() for o in raw.split(",") if o.strip()] + else: + frontend_url = os.getenv("FRONTEND_URL", "").strip() + configured = [frontend_url] if frontend_url else list(LOCAL_DEV_ORIGINS) + + seen: set[str] = set() + origins: list[str] = [] + for origin in [*configured, *MOBILE_APP_ORIGINS]: + if origin not in seen: + seen.add(origin) + origins.append(origin) + return origins + + +ALLOWED_ORIGINS = _allowed_origins() +logger.info("CORS allowed origins: %s", ALLOWED_ORIGINS) + +# The grievance/escalation service layer existed but was never mounted, so +# every path frontend/src/api/grievances.js calls returned 404. +from backend.grievance_routes import router as grievance_router # noqa: E402 + +app.include_router(grievance_router) -# Enable CORS app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) + # Dependency to get the database session def get_db(): db = SessionLocal() @@ -124,43 +322,115 @@ def get_db(): finally: db.close() + class PincodeRequest(BaseModel): pincode: str + class ChatRequest(BaseModel): - message: str - history: List[dict] = [] + """Chat payload. + + components/ChatWidget.jsx posts {"query": ...}. This model required + `message`, so every message sent from the widget was rejected with 422 and + the assistant never answered. Both names are accepted; `message` stays + canonical. + """ + + message: str | None = None + query: str | None = None + history: list[dict] = [] + + @property + def text(self) -> str: + value = self.message or self.query + if not value or not value.strip(): + raise HTTPException( + status_code=422, + detail="Provide a non-empty 'message' (or 'query').", + ) + return value -@app.get("/") -def read_root(): - return { - "status": "ok", - "service": "VishwaGuru API", - "version": "1.0.0" - } @app.get("/", response_model=SuccessResponse) def root(): return SuccessResponse( - message="VishwaGuru API is running", - data={ - "service": "VishwaGuru API", - "version": "1.0.0" - } + message="VishwaGuru API is running", data={"service": "VishwaGuru API", "version": "1.0.0"} ) + +def _database_status() -> tuple[bool, str]: + """Actually open a connection and run a statement.""" + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + if USING_SQLITE_FALLBACK: + # Reachable, but not the database this deployment was configured to + # use. Reported distinctly so a working service is not mistaken for + # a correctly configured one -- data here may not survive a restart. + return False, "sqlite-fallback: configured database unreachable" + return True, "connected" + except Exception as exc: + # The message is kept short: this is a public endpoint and the driver's + # full error carries the host name and connection string. + logger.error("Database health check failed: %s", exc) + return False, f"unreachable: {type(exc).__name__}" + + +def _ai_status() -> tuple[bool, str]: + try: + get_ai_services() + return True, "initialized" + except Exception: + return False, "not initialized" + + @app.get("/health", response_model=HealthResponse) +@limiter.exempt def health(): + """Liveness. 200 whenever the process can serve, so the platform does not + restart a service whose only problem is a dependency it cannot fix by + restarting. + + It used to report {"database": "connected"} as a hard-coded string without + ever opening a connection. The deployed Postgres instance was deleted and + every database-backed endpoint returned 500 for days, while this endpoint + kept answering "healthy" -- so the platform health gate passed and nothing + surfaced the outage. It now reports what it actually finds. + """ + db_ok, db_detail = _database_status() + ai_ok, ai_detail = _ai_status() + return HealthResponse( - status="healthy", - timestamp=datetime.now(timezone.utc), + status="healthy" if (db_ok and ai_ok) else "degraded", + timestamp=datetime.now(UTC), version="1.0.0", - services={ - "database": "connected", - "ai_services": "initialized" - } + services={"database": db_detail, "ai_services": ai_detail}, + ) + + +@app.get("/health/ready", response_model=HealthResponse) +@limiter.exempt +def readiness(response: Response): + """Readiness. 503 when a dependency the service needs is unavailable. + + Point alerting and load-balancer membership at this, not at /health: a + process that is alive but cannot reach its database should be taken out of + rotation, but restarting it will not bring the database back. + """ + db_ok, db_detail = _database_status() + ai_ok, ai_detail = _ai_status() + + if not db_ok: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + return HealthResponse( + status="healthy" if (db_ok and ai_ok) else "unhealthy" if not db_ok else "degraded", + timestamp=datetime.now(UTC), + version="1.0.0", + services={"database": db_detail, "ai_services": ai_detail}, ) + @app.get("/api/stats", response_model=StatsResponse) def get_stats(db: Session = Depends(get_db)): cached_stats = recent_issues_cache.get("stats") @@ -168,26 +438,29 @@ def get_stats(db: Session = Depends(get_db)): return JSONResponse(content=cached_stats) total = db.query(func.count(Issue.id)).scalar() - resolved = db.query(func.count(Issue.id)).filter(Issue.status.in_(['resolved', 'verified'])).scalar() + resolved = ( + db.query(func.count(Issue.id)).filter(Issue.status.in_(["resolved", "verified"])).scalar() + ) # Pending is everything else pending = total - resolved # By category cat_counts = db.query(Issue.category, func.count(Issue.id)).group_by(Issue.category).all() - issues_by_category = {cat: count for cat, count in cat_counts} + issues_by_category = dict(cat_counts) response = StatsResponse( total_issues=total, resolved_issues=resolved, pending_issues=pending, - issues_by_category=issues_by_category + issues_by_category=issues_by_category, ) - data = response.model_dump(mode='json') + data = response.model_dump(mode="json") recent_issues_cache.set(data, "stats") return response + @app.get("/api/ml-status", response_model=MLStatusResponse) async def ml_status(): """ @@ -198,9 +471,10 @@ async def ml_status(): return MLStatusResponse( status="ok", models_loaded=status.get("models_loaded", []), - memory_usage=status.get("memory_usage") + memory_usage=status.get("memory_usage"), ) + def save_file_blocking(file_obj, path): """ Save uploaded file with security measures: @@ -223,61 +497,227 @@ def save_file_blocking(file_obj, path): shutil.copyfileobj(file_obj, buffer) logger.info(f"Saved file {path} as binary (not an image or PIL failed)") -@app.post("/api/issues") + +def save_issue_db(db: Session, issue: Issue) -> Issue: + """Persist an issue. Named at module level so it can be run in a threadpool + and identified by callers that need to distinguish the two blocking steps.""" + db.add(issue) + db.commit() + db.refresh(issue) + return issue + + +def _coerce_action_plan(value): + """Action plans are stored as JSON text but handled as dicts in memory.""" + if value is None or isinstance(value, dict): + return value + try: + return json.loads(value) + except (TypeError, ValueError): + return None + + +def _serialise_issue_for_cache(issue: Issue) -> dict: + return { + "id": issue.id, + "category": issue.category, + "description": issue.description, + "created_at": issue.created_at, + "image_path": issue.image_path, + "status": issue.status, + "upvotes": issue.upvotes, + "location": issue.location, + "latitude": issue.latitude, + "longitude": issue.longitude, + "action_plan": _coerce_action_plan(issue.action_plan), + } + + +def _update_recent_cache(issue: Issue) -> None: + """Prepend the new issue to the cached recent list instead of dropping it. + + Invalidating forced the next reader to re-query, which is wasteful when the + only change is one row at the head of a list already in memory. The cache is + only invalidated when there is nothing to update. + """ + try: + cached = recent_issues_cache.get(RECENT_ISSUES_CACHE_KEY) + if not cached: + recent_issues_cache.invalidate(RECENT_ISSUES_CACHE_KEY) + return + updated = [_serialise_issue_for_cache(issue), *cached][:RECENT_ISSUES_LIMIT] + recent_issues_cache.set(updated, RECENT_ISSUES_CACHE_KEY) + except Exception: + logger.exception("Failed to update the recent-issues cache") + recent_issues_cache.invalidate(RECENT_ISSUES_CACHE_KEY) + + +async def _generate_action_plan_task(issue_id: int, description: str, category: str) -> None: + """Produce the action plan after the response has been sent. + + Generating it inline held the request open for the full duration of the + model call, so submitting a report appeared to hang. The client polls + /api/issues/recent, which now carries action_plan, until it is populated. + """ + try: + plan = await generate_action_plan(description, category) + except Exception: + logger.exception("Background action plan generation failed for issue %s", issue_id) + return + + session = SessionLocal() + try: + issue = session.query(Issue).filter(Issue.id == issue_id).first() + if issue is None: + logger.warning("Issue %s vanished before its action plan was stored", issue_id) + return + issue.action_plan = plan + session.commit() + logger.info("Stored action plan for issue %s", issue_id) + except Exception: + logger.exception("Failed to store action plan for issue %s", issue_id) + session.rollback() + finally: + session.close() + + +def _find_nearby(db: Session, latitude: float, longitude: float, radius_meters: float): + """Candidate issues near a point, nearest first.""" + candidates = ( + db.query(Issue).filter(Issue.latitude.isnot(None), Issue.longitude.isnot(None)).all() + ) + matches = find_nearby_issues(candidates, latitude, longitude, radius_meters) + return sorted(matches, key=lambda pair: pair[1]) + + +@app.get("/api/issues/nearby") +def get_nearby_issues( + latitude: float = Query(..., ge=-90, le=90), + longitude: float = Query(..., ge=-180, le=180), + radius: float = Query(50.0, gt=0, le=50000, description="Search radius in metres"), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), +): + """Issues within `radius` metres of a point, sorted by distance. + + The frontend's duplicate check called this and got a 404: backend/spatial_utils.py + implemented the geometry but nothing ever exposed it. + """ + matches = _find_nearby(db, latitude, longitude, radius)[:limit] + return [ + { + "id": issue.id, + "category": issue.category, + "description": issue.description, + "status": issue.status, + "upvotes": issue.upvotes, + "latitude": issue.latitude, + "longitude": issue.longitude, + "created_at": issue.created_at, + "distance_meters": round(distance, 2), + } + for issue, distance in matches + ] + + +@app.post("/api/issues", status_code=201) async def create_issue( + background_tasks: BackgroundTasks, description: str = Form(...), category: str = Form(...), source: str = Form("web"), - user_email: Optional[str] = Form(None), - image: UploadFile = File(...), - db: Session = Depends(get_db) + user_email: str | None = Form(None), + latitude: float | None = Form(None), + longitude: float | None = Form(None), + location: str | None = Form(None), + image: UploadFile | None = File(None), + db: Session = Depends(get_db), ): - try: - # Save the uploaded image - os.makedirs("data/uploads", exist_ok=True) - filename = f"{uuid.uuid4()}_{os.path.basename(image.filename)}" - file_location = f"data/uploads/{filename}" + """Record a civic issue. - # Offload blocking file I/O to a thread + Returns 201 with `action_plan` null. The plan is generated in the + background and collected by polling /api/issues/recent, because the model + call took long enough that submitting a report looked like a hang. + """ + file_location = None + if image is not None and image.filename: + await validate_uploaded_file(image) + await image.seek(0) + os.makedirs(UPLOAD_DIR, exist_ok=True) + filename = f"{uuid.uuid4()}_{os.path.basename(image.filename)}" + file_location = os.path.join(UPLOAD_DIR, filename) await run_in_threadpool(save_file_blocking, image.file, file_location) - # Generate Action Plan (AI) - action_plan = await generate_action_plan(description, category, file_location) - - # Offload blocking DB operations to a thread - def save_to_db(): - db_issue = Issue( - description=description, - category=category, - image_path=file_location, - source=source, - user_email=user_email - ) - db.add(db_issue) - db.commit() - db.refresh(db_issue) - return db_issue + deduplication_info = {"has_nearby_issues": False, "nearby_issues": []} + linked_issue_id = None - new_issue = await asyncio.to_thread(save_to_db) + if latitude is not None and longitude is not None: + try: + nearby = _find_nearby(db, latitude, longitude, DEDUPLICATION_RADIUS_METERS) + except Exception: + logger.exception("Nearby-issue lookup failed during issue creation") + nearby = [] - return { - "id": new_issue.id, - "message": "Issue reported successfully", - "action_plan": action_plan - } - except Exception as e: + if nearby: + deduplication_info = { + "has_nearby_issues": True, + "nearby_issues": [ + { + "id": existing.id, + "category": existing.category, + "description": existing.description, + "distance_meters": round(distance, 2), + } + for existing, distance in nearby[:5] + ], + } + linked_issue_id = nearby[0][0].id + + new_issue = Issue( + description=description, + category=category, + image_path=file_location, + source=source, + user_email=user_email, + latitude=latitude, + longitude=longitude, + location=location, + ) + + try: + saved = await run_in_threadpool(save_issue_db, db, new_issue) + except Exception as exc: logger.exception("Error creating issue") - return JSONResponse(status_code=500, content={"message": "An internal error occurred"}) + raise HTTPException(status_code=500, detail="Could not record the issue.") from exc + + if saved is None: + saved = new_issue + + _update_recent_cache(saved) + + background_tasks.add_task(_generate_action_plan_task, saved.id, description, category) + + return { + "id": saved.id, + "message": "Issue reported successfully", + "action_plan": None, + "deduplication_info": deduplication_info, + "linked_issue_id": linked_issue_id, + } + @lru_cache(maxsize=1) def _load_responsibility_map(): # Assuming the data folder is at the root level relative to where backend is run # Adjust path as necessary. If running from root, it is "data/responsibility_map.json" - file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json") + file_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json" + ) - with open(file_path, "r") as f: + with open(file_path) as f: return json.load(f) + @app.get("/api/responsibility-map") def get_responsibility_map(): # In a real app, this might read from the file or database @@ -287,6 +727,7 @@ def get_responsibility_map(): except FileNotFoundError: return {"error": "Data file not found"} + @app.get("/api/issues/recent") def get_recent_issues(db: Session = Depends(get_db)): # Fetch last 10 issues @@ -296,77 +737,66 @@ def get_recent_issues(db: Session = Depends(get_db)): { "id": i.id, "category": i.category, - "description": i.description[:100] + "..." if len(i.description) > 100 else i.description, + "description": i.description[:100] + "..." + if len(i.description) > 100 + else i.description, "created_at": i.created_at, "image_path": i.image_path, - "status": i.status + "status": i.status, + "upvotes": i.upvotes, + # ActionView.jsx polls this endpoint for the backgrounded action + # plan; without this field the poll could never terminate. + "action_plan": _coerce_action_plan(i.action_plan), } for i in issues ] -@app.post("/api/detect-pothole") -async def detect_pothole_endpoint(image: UploadFile = File(...)): - # Read image - contents = await image.read() - # Convert to PIL Image - try: - pil_image = Image.open(io.BytesIO(contents)) - except Exception: - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection (blocking, so run in threadpool) - try: - detections = await run_in_threadpool(detect_potholes, pil_image) - except Exception as e: - print(f"Error creating issue: {e}") - return JSONResponse(status_code=500, content={"message": str(e)}) @app.get("/api/issues") -def get_issues( - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db) -): +def get_issues(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): # Added pagination issues = db.query(Issue).offset(skip).limit(limit).all() return issues + @app.post("/api/mh/rep-contacts") async def get_rep_contacts_post(request: PincodeRequest): return await get_maharashtra_rep_contacts_logic(request.pincode) + @app.get("/api/mh/rep-contacts") async def get_rep_contacts_get(pincode: str = Query(..., min_length=6, max_length=6)): return await get_maharashtra_rep_contacts_logic(pincode) + async def get_maharashtra_rep_contacts_logic(pincode: str): # Logic extracted to support both GET and POST if not pincode.isdigit(): raise HTTPException(status_code=400, detail="Invalid pincode") - + constituency_info = find_constituency_by_pincode(pincode) - + if not constituency_info: # Fallback to just district check - raise HTTPException(status_code=404, detail="Unknown pincode") + raise HTTPException(status_code=404, detail="Unknown pincode") assembly_constituency = constituency_info.get("assembly_constituency") mla_info = None if assembly_constituency: mla_info = find_mla_by_constituency(assembly_constituency) - + if not mla_info: mla_info = { "mla_name": "MLA Info Unavailable", "party": "N/A", "phone": "N/A", "email": "N/A", - "twitter": "Not Available" + "twitter": "Not Available", } if not assembly_constituency: - constituency_info["assembly_constituency"] = "Unknown (District Found)" - + constituency_info["assembly_constituency"] = "Unknown (District Found)" + description = None try: if assembly_constituency and mla_info["mla_name"] != "MLA Info Unavailable": @@ -374,11 +804,15 @@ async def get_maharashtra_rep_contacts_logic(pincode: str): description = await ai_services.mla_summary_service.generate_mla_summary( district=constituency_info["district"], assembly_constituency=assembly_constituency, - mla_name=mla_info["mla_name"] + mla_name=mla_info["mla_name"], ) except Exception: - pass - + # The AI-written summary is optional garnish on the representative + # lookup; the contact details below are the answer. A failure here was + # silently discarded, so an outage in the summary service looked like + # the feature simply not having a description. + logger.warning("MLA summary generation failed", exc_info=True) + response = { "pincode": pincode, "state": constituency_info["state"], @@ -389,90 +823,116 @@ async def get_maharashtra_rep_contacts_logic(pincode: str): "party": mla_info["party"], "phone": mla_info["phone"], "email": mla_info["email"], - "twitter": mla_info.get("twitter") + "twitter": mla_info.get("twitter"), }, "grievance_links": { "central_cpgrams": "https://pgportal.gov.in/", "maharashtra_portal": "https://aaplesarkar.mahaonline.gov.in/en", - "note": "This is an MVP; data may not be fully accurate." - } + "note": "This is an MVP; data may not be fully accurate.", + }, } - + if description: response["description"] = description elif mla_info["mla_name"] == "MLA Info Unavailable": - response["description"] = f"We found that {pincode} belongs to {constituency_info['district']} district." + response["description"] = ( + f"We found that {pincode} belongs to {constituency_info['district']} district." + ) return response + @app.get("/api/mh/districts") async def get_districts(): - return {"districts": [d[2] for d in DISTRICT_RANGES]} if 'DISTRICT_RANGES' in globals() else {"districts": []} + return ( + {"districts": [d[2] for d in DISTRICT_RANGES]} + if "DISTRICT_RANGES" in globals() + else {"districts": []} + ) -@app.post("/api/detect-pothole") -async def api_detect_pothole(file: UploadFile = File(...)): + +# The four original detector handlers, rewritten to share one code path. +# +# They had three defects between them. detect_vandalism and detect_flooding are +# coroutine functions, but the vandalism handler called detect_vandalism inside +# a sync function handed to run_in_threadpool, so it produced an un-awaited +# coroutine that failed serialisation with a 500 on every request. The flooding +# handler awaited correctly but opened the image on the event loop. And none of +# the four enforced MAX_UPLOAD_SIZE_MB, so a phone photo above the limit was +# accepted here while the generated endpoints correctly rejected it. +# +# Detector callables are resolved through _service() so monkeypatching +# backend.main. in tests still works, and both sync and async +# implementations are supported. + + +async def _run_image_detector(service_name: str, upload: UploadFile) -> dict: + contents = await _read_upload(upload) try: - def process_image(): - img = PIL.Image.open(file.file) - return detect_potholes(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) + pil_image = await run_in_threadpool(Image.open, io.BytesIO(contents)) + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid image file.") from exc -@app.post("/api/detect-garbage") -async def api_detect_garbage(file: UploadFile = File(...)): + validate_image_for_processing(pil_image) + + detector = _service(service_name) try: - def process_image(): - img = PIL.Image.open(file.file) - return detect_garbage(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) + result = detector(pil_image) + if inspect.isawaitable(result): + result = await result + elif callable(getattr(result, "__await__", None)): # pragma: no cover + result = await result + except HTTPException: + raise + except Exception as exc: + logger.exception("%s failed", service_name) + raise HTTPException(status_code=502, detail="Detection service unavailable.") from exc + return {"detections": result} + + +@app.post("/api/detect-pothole") +@limiter.limit(AI_RATE_LIMIT) +async def api_detect_pothole(request: Request, image: UploadFile = File(...)): + return await _run_image_detector("detect_potholes", image) + + +@app.post("/api/detect-garbage") +@limiter.limit(AI_RATE_LIMIT) +async def api_detect_garbage(request: Request, image: UploadFile = File(...)): + return await _run_image_detector("detect_garbage", image) + @app.post("/api/detect-vandalism") -async def api_detect_vandalism(file: UploadFile = File(...)): - try: - if not os.getenv("HF_TOKEN") and not os.getenv("HUGGINGFACE_HUB_TOKEN"): - print("Warning: HF_TOKEN not set.") - def process_image(): - img = PIL.Image.open(file.file) - return detect_vandalism(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) +@limiter.limit(AI_RATE_LIMIT) +async def api_detect_vandalism(request: Request, image: UploadFile = File(...)): + return await _run_image_detector("detect_vandalism_unified", image) + @app.post("/api/detect-flooding") -async def api_detect_flooding(file: UploadFile = File(...)): - try: - img = PIL.Image.open(file.file) - result = await detect_flooding(img) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) +@limiter.limit(AI_RATE_LIMIT) +async def api_detect_flooding(request: Request, image: UploadFile = File(...)): + return await _run_image_detector("detect_flooding", image) + @app.post("/api/chat") async def chat_endpoint(request: ChatRequest): - try: - response = await chat_with_civic_assistant(request.message, request.history) - return {"response": response} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) + # Resolved before the try: request.text raises 422 for an empty message, and + # a broad handler would otherwise convert that client error into a 500. + message = request.text -@app.get("/api/responsibility-map") -async def get_responsibility_map_endpoint(): try: - data = await run_in_threadpool(get_responsible_authority) - return data - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) + response = await chat_with_civic_assistant(message, request.history) + except Exception as exc: + # The previous version returned the raw exception string to the caller, + # which leaks internals on any upstream failure. + logger.exception("Chat assistant failed") + raise HTTPException(status_code=502, detail="Chat service unavailable.") from exc + return {"response": response} + @app.post("/api/analyze-issue") async def analyze_issue_endpoint( - description: str = Form(...), - image: Optional[UploadFile] = File(None) + description: str = Form(...), image: UploadFile | None = File(None) ): try: image_path = None @@ -493,6 +953,7 @@ async def analyze_issue_endpoint( print(f"Analysis error: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) + @app.post("/api/issues/{issue_id}/upvote") def upvote_issue(issue_id: int, db: Session = Depends(get_db)): issue = db.query(Issue).filter(Issue.id == issue_id).first() @@ -505,3 +966,331 @@ def upvote_issue(issue_id: int, db: Session = Depends(get_db)): db.commit() db.refresh(issue) return {"status": "success", "upvotes": issue.upvotes} + + +# ============================================================================= +# Detector endpoints +# ============================================================================= +# +# Every service function called below already existed in +# backend/hf_api_service.py and backend/local_ml_service.py. None of them was +# ever routed, so the frontend called 15 endpoints that returned 404 in +# production. tests/test_api_contract.py now fails if that gap reopens. +# +# The handlers are generated from a table instead of being copy-pasted. The +# copy-paste approach is what produced four handlers that declared their upload +# field as `file` while every caller posted `image`. +# +# `service` is stored as a NAME and resolved from this module at request time, +# so tests that monkeypatch backend.main. take effect. + +MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_SIZE_MB", "10")) * 1024 * 1024 +UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.join("data", "uploads")) +RECENT_ISSUES_CACHE_KEY = "recent" +RECENT_ISSUES_LIMIT = 10 +# Reports closer than this to an existing one are flagged as possible duplicates. +DEDUPLICATION_RADIUS_METERS = 50.0 + + +class UrgencyRequest(BaseModel): + """frontend/src/views/ReportForm.jsx posts {"description": ...}. + + This model originally declared a single required `text` field, so every + request from the report form was rejected with 422 and the urgency panel + silently never populated -- ReportForm's catch only console.errors. + `text` is kept as an accepted alias. + """ + + description: str | None = None + text: str | None = None + + @property + def content(self) -> str: + value = self.description or self.text + if not value or not value.strip(): + raise HTTPException( + status_code=422, + detail="Provide a non-empty 'description' (or 'text').", + ) + return value + + +# Detector implementations are dispatched by name through _service() so that +# tests can monkeypatch backend.main.. Listing them here makes that +# indirection explicit: without it the imports read as dead to both linters and +# reviewers, and deleting one would break a route with nothing to catch it. +DETECTOR_IMPLEMENTATIONS = ( + detect_potholes, + detect_garbage, + detect_vandalism, + detect_vandalism_unified, + detect_flooding, + detect_infrastructure_local, + detect_infrastructure_unified, + detect_accessibility_issue_clip, + detect_audio_event, + detect_blocked_road_clip, + detect_civic_eye_clip, + detect_crowd_density_clip, + detect_depth_map, + detect_fire_clip, + detect_illegal_parking_clip, + detect_pest_clip, + detect_severity_clip, + detect_smart_scan_clip, + detect_stray_animal_clip, + detect_street_light_clip, + detect_tree_hazard_clip, + detect_waste_clip, + detect_water_leak_clip, + generate_image_caption, + transcribe_audio, + verify_resolution_vqa, + analyze_urgency_text, +) + + +def _service(name: str): + """Resolve a service function from this module at call time.""" + return getattr(sys.modules[__name__], name) + + +async def _read_upload(upload: UploadFile) -> bytes: + """Read an upload, enforcing the configured size ceiling. + + MAX_UPLOAD_SIZE_MB was declared in render.yaml and parsed in config.py but + was never actually enforced on any request path. + """ + contents = await upload.read() + if not contents: + raise HTTPException(status_code=400, detail="Empty upload.") + if len(contents) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"Upload exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB limit.", + ) + return contents + + +async def validate_uploaded_file(upload: UploadFile) -> bytes: + """Read and validate an uploaded image, returning its bytes.""" + contents = await _read_upload(upload) + try: + validate_image_file(contents) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid image: {exc}") from exc + return contents + + +def validate_image_for_processing(image: "Image.Image") -> None: + """Validate a decoded image before it is handed to a detector. + + validate_uploaded_file() checks the bytes; this checks the decoded result, + where a small payload can still expand to dimensions large enough to + exhaust memory during inference. + """ + width, height = image.size + if width <= 0 or height <= 0: + raise HTTPException(status_code=400, detail="Image has no pixels.") + if width > MAX_IMAGE_WIDTH or height > MAX_IMAGE_HEIGHT: + raise HTTPException( + status_code=413, + detail=f"Image dimensions {width}x{height} exceed the " + f"{MAX_IMAGE_WIDTH}x{MAX_IMAGE_HEIGHT} limit.", + ) + + +def _http_client(request: Request): + return getattr(request.app.state, "http_client", None) + + +# (route path, service function name, wrap result as {"detections": ...}) +DETECTOR_ENDPOINTS = [ + ("/api/detect-fire", "detect_fire_clip", True), + ("/api/detect-illegal-parking", "detect_illegal_parking_clip", True), + ("/api/detect-street-light", "detect_street_light_clip", True), + ("/api/detect-stray-animal", "detect_stray_animal_clip", True), + ("/api/detect-blocked-road", "detect_blocked_road_clip", True), + ("/api/detect-tree-hazard", "detect_tree_hazard_clip", True), + ("/api/detect-pest", "detect_pest_clip", True), + ("/api/detect-accessibility", "detect_accessibility_issue_clip", True), + ("/api/detect-crowd", "detect_crowd_density_clip", True), + ("/api/detect-water-leak", "detect_water_leak_clip", True), + ("/api/detect-severity", "detect_severity_clip", False), + ("/api/detect-smart-scan", "detect_smart_scan_clip", False), + ("/api/detect-waste", "detect_waste_clip", False), + ("/api/detect-civic-eye", "detect_civic_eye_clip", False), + ("/api/analyze-depth", "detect_depth_map", False), +] + + +def _make_detector_route(service_name: str, wrap: bool): + async def endpoint(request: Request, image: UploadFile = File(...)): + contents = await _read_upload(image) + try: + result = await _service(service_name)(contents, client=_http_client(request)) + except HTTPException: + raise + except Exception as exc: + logger.exception("%s failed", service_name) + raise HTTPException(status_code=502, detail="Detection service unavailable.") from exc + return {"detections": result} if wrap else result + + endpoint.__name__ = f"{service_name}_endpoint" + return endpoint + + +for _path, _service_name, _wrap in DETECTOR_ENDPOINTS: + # Each of these calls a paid inference API, so they use the tighter bucket. + app.post(_path)(limiter.limit(AI_RATE_LIMIT)(_make_detector_route(_service_name, _wrap))) + + +@app.post("/api/detect-infrastructure") +@limiter.limit(AI_RATE_LIMIT) +async def detect_infrastructure_endpoint(request: Request, image: UploadFile = File(...)): + """Infrastructure damage goes through the unified service. + + This used to call detect_infrastructure_local directly, so a deployment + without the local model had no path to the hosted API at all. + """ + return await _run_image_detector("detect_infrastructure_unified", image) + + +@app.post("/api/transcribe-audio") +@limiter.limit(AI_RATE_LIMIT) +async def transcribe_audio_endpoint(request: Request, file: UploadFile = File(...)): + """The upload field is `file` here, matching the audio caller; image + endpoints use `image`. transcribe_audio() returns a bare string, so it is + wrapped rather than returned directly.""" + contents = await _read_upload(file) + try: + text = await transcribe_audio(contents, client=_http_client(request)) + except Exception as exc: + logger.exception("Audio transcription failed") + raise HTTPException(status_code=502, detail="Transcription service unavailable.") from exc + return {"text": text} + + +@app.post("/api/detect-audio") +@limiter.limit(AI_RATE_LIMIT) +async def detect_audio_endpoint(request: Request, file: UploadFile = File(...)): + """Noise classification. NoiseDetector.jsx posts the recording as `file` + and reads `data.detections`.""" + contents = await _read_upload(file) + try: + detections = await detect_audio_event(contents, client=_http_client(request)) + except Exception as exc: + logger.exception("Audio event detection failed") + raise HTTPException(status_code=502, detail="Audio detection service unavailable.") from exc + return {"detections": detections} + + +@app.post("/api/generate-description") +@limiter.limit(AI_RATE_LIMIT) +async def generate_description_endpoint(request: Request, image: UploadFile = File(...)): + contents = await _read_upload(image) + try: + caption = await generate_image_caption(contents, client=_http_client(request)) + except Exception as exc: + logger.exception("Caption generation failed") + raise HTTPException(status_code=502, detail="Captioning service unavailable.") from exc + return {"description": caption} + + +@app.post("/api/analyze-urgency") +@limiter.limit(AI_RATE_LIMIT) +async def analyze_urgency_endpoint(request: Request, payload: UrgencyRequest): + # Resolved before the try for the same reason as /api/chat: payload.content + # raises 422 on an empty description, and the handler below would otherwise + # relabel that client error as an upstream outage. + description = payload.content + + try: + return await analyze_urgency_text(description, client=_http_client(request)) + except Exception as exc: + logger.exception("Urgency analysis failed") + raise HTTPException(status_code=502, detail="Urgency service unavailable.") from exc + + +@app.get("/api/leaderboard") +def get_leaderboard(limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db)): + """Top reporters by number of issues filed, with their total upvotes.""" + rows = ( + db.query( + Issue.user_email.label("user_email"), + func.count(Issue.id).label("reports_count"), + func.coalesce(func.sum(Issue.upvotes), 0).label("upvotes"), + ) + .filter(Issue.user_email.isnot(None)) + .group_by(Issue.user_email) + .order_by(func.count(Issue.id).desc(), func.sum(Issue.upvotes).desc()) + .limit(limit) + .all() + ) + return { + "leaderboard": [ + { + "rank": index, + "user_email": row.user_email, + "reports_count": int(row.reports_count or 0), + "upvotes": int(row.upvotes or 0), + } + for index, row in enumerate(rows, start=1) + ] + } + + +@app.post("/api/issues/{issue_id}/verify") +@limiter.limit(AI_RATE_LIMIT) +async def verify_issue_resolution( + request: Request, + issue_id: int, + image: UploadFile = File(...), + db: Session = Depends(get_db), + _api_key: str = Depends(require_api_key), +): + """Citizen uploads a photo; a VQA model judges whether the issue is fixed. + + Requires X-API-Key: this writes issue.status, which is what officials and + the public dashboard treat as the record of whether a problem was fixed. + + The response carries `confidence` and `question_asked` because + frontend/src/views/VerifyView.jsx renders both directly -- it computes + `(result.confidence * 100).toFixed(1)`, which shows "NaN%" if the field is + absent, and interpolates `result.question_asked` into its summary line. + """ + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found.") + + contents = await validate_uploaded_file(image) + + question = f"Is this {issue.category or 'civic issue'} still present in the image?" + try: + answer = await verify_resolution_vqa(contents, question, client=_http_client(request)) + except Exception as exc: + logger.exception("Resolution verification failed") + raise HTTPException(status_code=502, detail="Verification service unavailable.") from exc + + if isinstance(answer, dict): + raw_answer = answer.get("answer") + confidence = answer.get("confidence", 0) + else: + raw_answer = answer + confidence = 0 + + ai_answer = str(raw_answer).strip().lower() + is_resolved = ai_answer == "no" + + issue.status = "verified" if is_resolved else "open" + db.commit() + + return { + "issue_id": issue_id, + "is_resolved": is_resolved, + "ai_answer": ai_answer, + "confidence": float(confidence or 0), + "question_asked": question, + } diff --git a/backend/main_fixed.py b/backend/main_fixed.py deleted file mode 100644 index 27c42cac..00000000 --- a/backend/main_fixed.py +++ /dev/null @@ -1,990 +0,0 @@ -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Request, Depends, BackgroundTasks -from fastapi.responses import JSONResponse -from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware -from fastapi.concurrency import run_in_threadpool -from sqlalchemy.orm import Session -from pydantic import BaseModel -from contextlib import asynccontextmanager -from functools import lru_cache -from typing import List -from datetime import datetime, timedelta, timezone -from PIL import Image - -import json -import os -import shutil -import uuid -import asyncio -import logging -import time -import magic -import httpx - -from backend.cache import recent_issues_cache -from backend.database import engine, Base, SessionLocal, get_db -from backend.models import Issue -from backend.schemas import ( - IssueResponse, IssueCreateRequest, IssueCreateResponse, ChatRequest, ChatResponse, - VoteRequest, VoteResponse, DetectionResponse, VisionAnalysisResponse, - UrgencyAnalysisRequest, UrgencyAnalysisResponse, HealthResponse, MLStatusResponse, - ResponsibilityMapResponse, ErrorResponse, SuccessResponse, IssueCategory, IssueStatus, - FollowerCreateRequest, FollowerResponse, BlockchainVerificationResponse -) -from backend.exceptions import EXCEPTION_HANDLERS -from backend.bot import application -from backend.ai_factory import create_all_ai_services -from backend.ai_service import ( - generate_action_plan, chat_with_civic_assistant, - analyze_issue_image, analyze_issue_with_ai, - VISION_MODEL, API_MODE -) -from backend.maharashtra_locator import ( - load_maharashtra_pincode_data, - load_maharashtra_mla_data, - find_constituency_by_pincode, - find_mla_by_constituency -) -from backend.init_db import migrate_db -from backend.grievance_service import GrievanceService -from backend.pothole_detection import detect_potholes, validate_image_for_processing -from backend.garbage_detection import detect_garbage -from backend.local_ml_service import ( - detect_infrastructure_local, - detect_flooding_local, - detect_vandalism_local, - get_detection_status -) -from backend.gemini_services import get_ai_services, initialize_ai_services -from backend.hf_api_service import ( - detect_illegal_parking_clip, - detect_street_light_clip, - detect_fire_clip, - detect_stray_animal_clip, - detect_blocked_road_clip, - detect_tree_hazard_clip, - detect_pest_clip, - detect_severity_clip, - detect_smart_scan_clip, - generate_image_caption, - analyze_urgency_text -) - -# Configure structured logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# File upload validation constants -MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB -ALLOWED_MIME_TYPES = { - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/bmp', - 'image/tiff' -} - -def _validate_uploaded_file_sync(file: UploadFile) -> None: - """ - Synchronous validation logic to be run in a threadpool. - """ - # Check file size - file.file.seek(0, 2) # Seek to end - file_size = file.file.tell() - file.file.seek(0) # Reset to beginning - - if file_size > MAX_FILE_SIZE: - raise HTTPException( - status_code=413, - detail=f"File too large. Maximum size allowed is {MAX_FILE_SIZE // (1024*1024)}MB" - ) - - # Check MIME type from content using python-magic - try: - # Read first 1024 bytes for MIME detection - file_content = file.file.read(1024) - file.file.seek(0) # Reset file pointer - - detected_mime = magic.from_buffer(file_content, mime=True) - - if detected_mime not in ALLOWED_MIME_TYPES: - raise HTTPException( - status_code=400, - detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" - ) - except Exception as e: - logger.error(f"Error validating file {file.filename}: {e}") - raise HTTPException( - status_code=400, - detail="Unable to validate file content. Please ensure it's a valid image file." - ) - -async def validate_uploaded_file(file: UploadFile) -> None: - """ - Validate uploaded file for security and safety (async wrapper). - - Args: - file: The uploaded file to validate - - Raises: - HTTPException: If validation fails - """ - await run_in_threadpool(_validate_uploaded_file_sync, file) - -# Create tables if they don't exist -Base.metadata.create_all(bind=engine) - -async def process_action_plan_background(issue_id: int, description: str, category: str, image_path: str): - db = SessionLocal() - try: - # Generate Action Plan (AI) - action_plan = await generate_action_plan(description, category, image_path) - - # Update issue in DB - issue = db.query(Issue).filter(Issue.id == issue_id).first() - if issue: - issue.action_plan = action_plan - db.commit() - - # Invalidate cache to ensure users get the updated action plan - recent_issues_cache.invalidate() - except Exception as e: - logger.error(f"Background action plan generation failed for issue {issue_id}: {e}", exc_info=True) - finally: - db.close() - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup: Migrate DB - migrate_db() - - # Startup: Initialize Shared HTTP Client for external APIs (Connection Pooling) - app.state.http_client = httpx.AsyncClient() - logger.info("Shared HTTP Client initialized.") - - # Startup: Initialize AI services - try: - action_plan_service, chat_service, mla_summary_service = create_all_ai_services() - - initialize_ai_services( - action_plan_service=action_plan_service, - chat_service=chat_service, - mla_summary_service=mla_summary_service - ) - logger.info("AI services initialized successfully.") - except Exception as e: - logger.error(f"Error initializing AI services: {e}", exc_info=True) - raise - - # Startup: Load static data to avoid first-request latency - try: - # These functions use lru_cache, so calling them once loads the data into memory - load_maharashtra_pincode_data() - load_maharashtra_mla_data() - logger.info("Maharashtra data pre-loaded successfully.") - except Exception as e: - logger.error(f"Error pre-loading Maharashtra data: {e}") - - # Startup: Start Telegram Bot - try: - if application: - await application.initialize() - await application.updater.start_polling() - await application.start() - logger.info("Telegram bot started.") - except Exception as e: - logger.error(f"Error starting Telegram bot: {e}") - - yield - - # Shutdown: Close Shared HTTP Client - await app.state.http_client.aclose() - logger.info("Shared HTTP Client closed.") - - # Shutdown: Stop Telegram Bot - try: - if application: - await application.updater.stop() - await application.stop() - await application.shutdown() - logger.info("Telegram bot stopped.") - except Exception as e: - logger.error(f"Error stopping Telegram bot: {e}") - -app = FastAPI( - title="VishwaGuru Backend", - description="AI-powered civic issue reporting and resolution platform", - version="1.0.0", - lifespan=lifespan -) - -# Add centralized exception handlers -for exception_type, handler in EXCEPTION_HANDLERS.items(): - app.add_exception_handler(exception_type, handler) - -# CORS Configuration - Security Enhanced -frontend_url = os.environ.get("FRONTEND_URL") -if not frontend_url: - raise ValueError( - "FRONTEND_URL environment variable is required for security. " - "Set it to your frontend URL (e.g., https://your-app.netlify.app). " - "For development, use http://localhost:5173 or similar." - ) - -# Validate URL format (basic check) -if not (frontend_url.startswith("http://") or frontend_url.startswith("https://")): - raise ValueError( - f"FRONTEND_URL must be a valid HTTP/HTTPS URL. Got: {frontend_url}" - ) - -# Build allowed origins list -allowed_origins = [frontend_url] - -# Allow localhost origins for development -if os.environ.get("ENVIRONMENT", "").lower() != "production": - # Add common development origins - dev_origins = [ - "http://localhost:3000", # React default - "http://localhost:5173", # Vite default - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - "http://localhost:8080", # Alternative dev port - ] - allowed_origins.extend(dev_origins) - -# Allow CORS for frontend -app.add_middleware( - CORSMiddleware, - allow_origins=allowed_origins, - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allow_headers=["*"], -) - -# Enable Gzip compression -app.add_middleware(GZipMiddleware, minimum_size=500) - -@app.get("/", response_model=SuccessResponse) -def root(): - return SuccessResponse( - message="VishwaGuru API is running", - data={ - "service": "VishwaGuru API", - "version": "1.0.0" - } - ) - -@app.get("/health", response_model=HealthResponse) -def health(): - return HealthResponse( - status="healthy", - timestamp=datetime.now(timezone.utc), - version="1.0.0", - services={ - "database": "connected", - "ai_services": "initialized" - } - ) - -@app.get("/api/ml-status", response_model=MLStatusResponse) -async def ml_status(): - """ - Get the status of the ML detection service. - Returns information about which backend is being used (local or HF API). - """ - status = await get_detection_status() - return MLStatusResponse( - status="ok", - models_loaded=status.get("models_loaded", []), - memory_usage=status.get("memory_usage") - ) - -def save_file_blocking(file_obj, path): - with open(path, "wb") as buffer: - shutil.copyfileobj(file_obj, buffer) - -def save_issue_db(db: Session, issue: Issue): - db.add(issue) - db.commit() - db.refresh(issue) - return issue - -@app.post("/api/issues", response_model=IssueCreateResponse, status_code=201) -async def create_issue( - background_tasks: BackgroundTasks, - description: str = Form(..., min_length=10, max_length=1000), - category: str = Form(..., pattern=f"^({'|'.join([cat.value for cat in IssueCategory])})$"), - user_email: str = Form(None), - latitude: float = Form(None, ge=-90, le=90), - longitude: float = Form(None, ge=-180, le=180), - location: str = Form(None, max_length=200), - image: UploadFile = File(None), - db: Session = Depends(get_db) -): - image_path = None - - try: - # Validate uploaded image if provided - if image: - await validate_uploaded_file(image) - - # Save image if provided - if image: - upload_dir = "data/uploads" - os.makedirs(upload_dir, exist_ok=True) - filename = f"{uuid.uuid4()}_{image.filename}" - image_path = os.path.join(upload_dir, filename) - await run_in_threadpool(save_file_blocking, image.file, image_path) - except HTTPException: - # Re-raise HTTP exceptions (from validation) - raise - except OSError as e: - logger.error(f"File I/O error while saving image: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to save uploaded file") - except Exception as e: - logger.error(f"Unexpected error during file processing: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - try: - # Save to DB - new_issue = Issue( - description=description, - category=category, - image_path=image_path, - source="web", - user_email=user_email, - latitude=latitude, - longitude=longitude, - location=location, - action_plan=None - ) - - # Offload blocking DB operations to threadpool - await run_in_threadpool(save_issue_db, db, new_issue) - except Exception as e: - # Clean up uploaded file if DB save failed - if image_path and os.path.exists(image_path): - try: - os.remove(image_path) - except OSError: - pass # Ignore cleanup errors - - logger.error(f"Database error while creating issue: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to save issue to database") - - # Add background task for AI generation - background_tasks.add_task(process_action_plan_background, new_issue.id, description, category, image_path) - - # Optimistic Cache Update - try: - current_cache = recent_issues_cache.get() - if current_cache: - # Create a dict representation of the new issue (similar to IssueResponse) - new_issue_dict = IssueResponse( - id=new_issue.id, - category=new_issue.category, - description=new_issue.description[:100] + "..." if len(new_issue.description) > 100 else new_issue.description, - created_at=new_issue.created_at, - image_path=new_issue.image_path, - status=new_issue.status, - upvotes=new_issue.upvotes if new_issue.upvotes is not None else 0, - location=new_issue.location, - latitude=new_issue.latitude, - longitude=new_issue.longitude, - action_plan=new_issue.action_plan - ).model_dump(mode='json') - - # Prepend new issue to the list - current_cache.insert(0, new_issue_dict) - - # Keep only last 10 (or matching the limit in get_recent_issues) - if len(current_cache) > 10: - current_cache.pop() - - recent_issues_cache.set(current_cache) - except Exception as e: - logger.error(f"Error updating cache optimistically: {e}") - # Failure to update cache is not critical, don't fail the request - - return IssueCreateResponse( - id=new_issue.id, - message="Issue reported successfully. Action plan will be generated shortly.", - action_plan=None - ) - -@app.post("/api/issues/{issue_id}/vote", response_model=VoteResponse) -def upvote_issue(issue_id: int, db: Session = Depends(get_db)): - issue = db.query(Issue).filter(Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - - # Increment upvotes - if issue.upvotes is None: - issue.upvotes = 0 - issue.upvotes += 1 - - db.commit() - db.refresh(issue) - - return VoteResponse( - id=issue.id, - upvotes=issue.upvotes, - message="Issue upvoted successfully" - ) - -@lru_cache(maxsize=1) -def _load_responsibility_map(): - file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json") - with open(file_path, "r") as f: - return json.load(f) - -@app.get("/api/responsibility-map", response_model=ResponsibilityMapResponse) -def get_responsibility_map(): - """Get responsibility mapping data for civic authorities""" - try: - data = _load_responsibility_map() - return ResponsibilityMapResponse(data=data) - except FileNotFoundError: - logger.error("Responsibility map file not found", exc_info=True) - raise HTTPException(status_code=404, detail="Responsibility map data not found") - except Exception as e: - logger.error(f"Error loading responsibility map: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to load responsibility map") - -@app.post("/api/analyze-urgency", response_model=UrgencyAnalysisResponse) -async def analyze_urgency_endpoint(request: Request, urgency_req: UrgencyAnalysisRequest): - try: - client = request.app.state.http_client - result = await analyze_urgency_text(urgency_req.description, client=client) - return UrgencyAnalysisResponse( - urgency_level=result.get("urgency_level", "medium"), - reasoning=result.get("reasoning", "Analysis completed"), - recommended_actions=result.get("recommended_actions", []) - ) - except Exception as e: - logger.error(f"Urgency analysis error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Urgency analysis service temporarily unavailable") - -@app.post("/api/chat", response_model=ChatResponse) -async def chat_endpoint(request: ChatRequest): - try: - response = await chat_with_civic_assistant(request.query) - return ChatResponse(response=response) - except Exception as e: - logger.error(f"Chat service error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Chat service temporarily unavailable") - - -# ── NVIDIA NIM Vision Analysis ───────────────────────────────────────────────── - -@app.post("/api/vision/analyze", response_model=VisionAnalysisResponse) -async def vision_analyze_endpoint( - image: UploadFile = File(...), - description: str = Form(""), -): - """ - Analyze an uploaded image using NVIDIA NIM vision model - (meta/llama-3.2-90b-vision-instruct). - - Detects civic issues, categorizes them, and assesses severity. - Optionally accepts a text description for enhanced analysis. - """ - if API_MODE == "none": - raise HTTPException( - status_code=503, - detail="Vision analysis unavailable — no AI API key configured" - ) - - # Validate the uploaded file - await validate_uploaded_file(image) - - # Save temporarily - upload_dir = "data/uploads" - os.makedirs(upload_dir, exist_ok=True) - filename = f"{uuid.uuid4()}_{image.filename}" - image_path = os.path.join(upload_dir, filename) - - try: - await run_in_threadpool(save_file_blocking, image.file, image_path) - - if description.strip(): - # Combined text + image analysis - result = await analyze_issue_with_ai(description, image_path) - return VisionAnalysisResponse( - description=result.get("category", "Unknown") + " issue detected", - category=result.get("category", "Unknown"), - severity=result.get("severity", "Medium"), - authority=result.get("authority"), - action_plan=result.get("action_plan"), - model_used=VISION_MODEL or "fallback", - ) - else: - # Image-only analysis - result = await analyze_issue_image(image_path) - return VisionAnalysisResponse( - description=result.get("description", "Could not analyze image"), - category=result.get("category", "Unknown"), - severity=result.get("severity", "Unknown"), - authority=None, - action_plan=None, - model_used=VISION_MODEL or "fallback", - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Vision analysis error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Vision analysis failed") - finally: - # Clean up temp file - try: - if os.path.exists(image_path): - os.remove(image_path) - except OSError: - pass - - -# Initialize GrievanceService for the endpoints -grievance_service = GrievanceService() - -@app.post("/api/grievances/{grievance_id}/follow", response_model=FollowerResponse) -async def follow_grievance_endpoint( - grievance_id: int, - request: FollowerCreateRequest, - db: Session = Depends(get_db) -): - """ - Follow a grievance with blockchain-style integrity hash. - Bolt Optimization: Uses O(1) in-memory cache for hash chaining. - """ - follower = await run_in_threadpool( - grievance_service.follow_grievance, - grievance_id, - request.user_email, - db - ) - if not follower: - raise HTTPException(status_code=400, detail="Failed to follow grievance") - return follower - -@app.get("/api/follower/{follower_id}/blockchain-verify", response_model=BlockchainVerificationResponse) -async def verify_follower_endpoint(follower_id: int, db: Session = Depends(get_db)): - """ - Verify the cryptographic integrity of a follower record. - """ - result = await run_in_threadpool( - grievance_service.verify_follower_integrity, - follower_id, - db - ) - return result - -@app.get("/api/issues/recent", response_model=List[IssueResponse]) -def get_recent_issues(db: Session = Depends(get_db)): - cached_data = recent_issues_cache.get() - if cached_data: - return JSONResponse(content=cached_data) - - # Fetch last 10 issues - issues = db.query(Issue).order_by(Issue.created_at.desc()).limit(10).all() - - # Convert to Pydantic models for validation and serialization - data = [] - for i in issues: - data.append(IssueResponse( - id=i.id, - category=i.category, - description=i.description[:100] + "..." if len(i.description) > 100 else i.description, - created_at=i.created_at, - image_path=i.image_path, - status=i.status, - upvotes=i.upvotes if i.upvotes is not None else 0, - location=i.location, - latitude=i.latitude, - longitude=i.longitude, - action_plan=i.action_plan - ).model_dump(mode='json')) - - recent_issues_cache.set(data) - return data - -# FIXED: Standardized Detection Endpoints with Consistent Validation -@app.post("/api/detect-pothole", response_model=DetectionResponse) -async def detect_pothole_endpoint(image: UploadFile = File(...)): - # Validate uploaded file - await validate_uploaded_file(image) - - # Convert to PIL Image directly from file object to save memory - try: - pil_image = await run_in_threadpool(Image.open, image.file) - # Validate image for processing - await run_in_threadpool(validate_image_for_processing, pil_image) - except HTTPException: - raise # Re-raise HTTP exceptions from validation - except Exception as e: - logger.error(f"Invalid image file for pothole detection: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection (blocking, so run in threadpool) - try: - detections = await run_in_threadpool(detect_potholes, pil_image) - return DetectionResponse(detections=detections) - except Exception as e: - logger.error(f"Pothole detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Pothole detection service temporarily unavailable") - -@app.post("/api/detect-infrastructure", response_model=DetectionResponse) -async def detect_infrastructure_endpoint(request: Request, image: UploadFile = File(...)): - # Validate uploaded file - await validate_uploaded_file(image) - - # Convert to PIL Image directly from file object to save memory - try: - pil_image = await run_in_threadpool(Image.open, image.file) - # Validate image for processing - await run_in_threadpool(validate_image_for_processing, pil_image) - except HTTPException: - raise # Re-raise HTTP exceptions from validation - except Exception as e: - logger.error(f"Invalid image file for infrastructure detection: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection using unified service (local ML by default) - try: - # Use shared HTTP client from app state - client = request.app.state.http_client - detections = await detect_infrastructure_local(pil_image, client=client) - return DetectionResponse(detections=detections) - except Exception as e: - logger.error(f"Infrastructure detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Infrastructure detection service temporarily unavailable") - -# FIXED: Single flooding detection endpoint with proper async validation -@app.post("/api/detect-flooding", response_model=DetectionResponse) -async def detect_flooding_endpoint(request: Request, image: UploadFile = File(...)): - # Validate uploaded file - await validate_uploaded_file(image) - - # Convert to PIL Image directly from file object to save memory - try: - pil_image = await run_in_threadpool(Image.open, image.file) - # Validate image for processing - await run_in_threadpool(validate_image_for_processing, pil_image) - except HTTPException: - raise # Re-raise HTTP exceptions from validation - except Exception as e: - logger.error(f"Invalid image file for flooding detection: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection using unified service (local ML by default) - try: - # Use shared HTTP client from app state - client = request.app.state.http_client - detections = await detect_flooding_local(pil_image, client=client) - return DetectionResponse(detections=detections) - except Exception as e: - logger.error(f"Flooding detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Flooding detection service temporarily unavailable") - -@app.post("/api/detect-vandalism", response_model=DetectionResponse) -async def detect_vandalism_endpoint(request: Request, image: UploadFile = File(...)): - # Validate uploaded file - await validate_uploaded_file(image) - - # Convert to PIL Image directly from file object to save memory - try: - pil_image = await run_in_threadpool(Image.open, image.file) - # Validate image for processing - await run_in_threadpool(validate_image_for_processing, pil_image) - except HTTPException: - raise # Re-raise HTTP exceptions from validation - except Exception as e: - logger.error(f"Invalid image file for vandalism detection: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection using unified service (local ML by default) - try: - # Use shared HTTP client from app state - client = request.app.state.http_client - detections = await detect_vandalism_local(pil_image, client=client) - return DetectionResponse(detections=detections) - except Exception as e: - logger.error(f"Vandalism detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Detection service temporarily unavailable") - -@app.post("/api/detect-garbage", response_model=DetectionResponse) -async def detect_garbage_endpoint(image: UploadFile = File(...)): - # Validate uploaded file - await validate_uploaded_file(image) - - # Convert to PIL Image directly from file object to save memory - try: - pil_image = await run_in_threadpool(Image.open, image.file) - # Validate image for processing - await run_in_threadpool(validate_image_for_processing, pil_image) - except HTTPException: - raise # Re-raise HTTP exceptions from validation - except Exception as e: - logger.error(f"Invalid image file for garbage detection: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - # Run detection (blocking, so run in threadpool) - try: - detections = await run_in_threadpool(detect_garbage, pil_image) - return DetectionResponse(detections=detections) - except Exception as e: - logger.error(f"Garbage detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Detection service temporarily unavailable") - -# External API Detection Endpoints (HuggingFace CLIP-based) -@app.post("/api/detect-illegal-parking") -async def detect_illegal_parking_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_illegal_parking_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Illegal parking detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-street-light") -async def detect_street_light_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_street_light_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Street light detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-fire") -async def detect_fire_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_fire_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Fire detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-stray-animal") -async def detect_stray_animal_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_stray_animal_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Stray animal detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-blocked-road") -async def detect_blocked_road_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_blocked_road_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Blocked road detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-tree-hazard") -async def detect_tree_hazard_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_tree_hazard_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Tree hazard detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-pest") -async def detect_pest_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - detections = await detect_pest_clip(image_bytes, client=client) - return {"detections": detections} - except Exception as e: - logger.error(f"Pest detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-severity") -async def detect_severity_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - result = await detect_severity_clip(image_bytes, client=client) - return result - except Exception as e: - logger.error(f"Severity detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/detect-smart-scan") -async def detect_smart_scan_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - result = await detect_smart_scan_clip(image_bytes, client=client) - return result - except Exception as e: - logger.error(f"Smart scan detection error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.post("/api/generate-description") -async def generate_description_endpoint(request: Request, image: UploadFile = File(...)): - try: - image_bytes = await image.read() - except Exception as e: - logger.error(f"Invalid image file: {e}", exc_info=True) - raise HTTPException(status_code=400, detail="Invalid image file") - - try: - client = request.app.state.http_client - description = await generate_image_caption(image_bytes, client=client) - if not description: - return {"description": "", "error": "Could not generate description"} - return {"description": description} - except Exception as e: - logger.error(f"Description generation error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - -@app.get("/api/mh/rep-contacts") -async def get_maharashtra_rep_contacts(pincode: str = Query(..., min_length=6, max_length=6)): - """ - Get MLA and representative contact information for Maharashtra by pincode. - """ - # Validate pincode format - if not pincode.isdigit(): - raise HTTPException( - status_code=400, - detail="Invalid pincode format. Must be 6 digits." - ) - - # Find constituency by pincode - constituency_info = find_constituency_by_pincode(pincode) - - if not constituency_info: - raise HTTPException( - status_code=404, - detail="Unknown pincode for Maharashtra MVP. Currently only supporting limited pincodes." - ) - - # Find MLA by constituency - assembly_constituency = constituency_info.get("assembly_constituency") - mla_info = None - - if assembly_constituency: - mla_info = find_mla_by_constituency(assembly_constituency) - - # If explicit MLA lookup failed or wasn't possible, create a generic placeholder - if not mla_info: - mla_info = { - "mla_name": "MLA Info Unavailable", - "party": "N/A", - "phone": "N/A", - "email": "N/A", - "twitter": "Not Available" - } - # If we have a district but no constituency, explain it - if not assembly_constituency: - constituency_info["assembly_constituency"] = "Unknown (District Found)" - - # Generate AI summary (optional) - description = None - try: - # Only generate summary if we have a valid constituency and MLA - if assembly_constituency and mla_info["mla_name"] != "MLA Info Unavailable": - ai_services = get_ai_services() - description = await ai_services.mla_summary_service.generate_mla_summary( - district=constituency_info["district"], - assembly_constituency=assembly_constituency, - mla_name=mla_info["mla_name"] - ) - except Exception as e: - logger.error(f"Error generating MLA summary: {e}") - # Continue without description - - # Build response - response = { - "pincode": pincode, - "state": constituency_info["state"], - "district": constituency_info["district"], - "assembly_constituency": constituency_info["assembly_constituency"], - "mla": { - "name": mla_info["mla_name"], - "party": mla_info["party"], - "phone": mla_info["phone"], - "email": mla_info["email"], - "twitter": mla_info.get("twitter") - }, - "grievance_links": { - "central_cpgrams": "https://pgportal.gov.in/", - "maharashtra_portal": "https://aaplesarkar.mahaonline.gov.in/en", - "note": "This is an MVP; data may not be fully accurate." - } - } - - # Add description if generated - if description: - response["description"] = description - elif mla_info["mla_name"] == "MLA Info Unavailable": - response["description"] = f"We found that {pincode} belongs to {constituency_info['district']} district, but we don't have the specific MLA details for this exact pincode yet." - - return response - -# Note: Frontend serving code removed for separate deployment -# The frontend will be deployed on Netlify and make API calls to this backend \ No newline at end of file diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 00000000..790f1d1f --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,80 @@ +"""Alembic environment. + +The database URL comes from the application's own configuration rather than +alembic.ini, so migrations always target the same database the service does and +there is no second place to keep in sync. + +Batch mode is on because SQLite is the local fallback and cannot ALTER a column +in place; without it, any migration that changes or drops a column fails there +while passing against PostgreSQL. +""" + +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Alembic runs this file directly, so the repository root has to be importable +# before `backend.*` will resolve. +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from backend.database import SQLALCHEMY_DATABASE_URL # noqa: E402 +from backend.models import Base # noqa: E402 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Escape '%' so ConfigParser interpolation does not choke on a URL containing +# percent-encoded credentials. +config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL.replace("%", "%%")) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Emit SQL to stdout instead of running it, for review or manual apply.""" + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + # Detect column type changes, which alembic ignores by default. + compare_type=True, + # Required for SQLite: it cannot ALTER a column, so alembic + # rebuilds the table instead. + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 00000000..af1fc169 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,33 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# Autogenerate renders custom column types by their fully qualified name +# (e.g. backend.models.JSONEncodedDict), so the module must be importable +# here or the migration fails with NameError at upgrade time. +import backend.models # noqa: F401 +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/67dd0262a3fd_baseline_schema.py b/backend/migrations/versions/67dd0262a3fd_baseline_schema.py new file mode 100644 index 00000000..21b0ae79 --- /dev/null +++ b/backend/migrations/versions/67dd0262a3fd_baseline_schema.py @@ -0,0 +1,259 @@ +"""baseline schema + +Revision ID: 67dd0262a3fd +Revises: +Create Date: 2026-08-19 19:03:41.216426 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +import backend.models # noqa: F401 + +# revision identifiers, used by Alembic. +revision: str = "67dd0262a3fd" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "issues", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reference_id", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("category", sa.String(), nullable=True), + sa.Column("image_path", sa.String(), nullable=True), + sa.Column("source", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("user_email", sa.String(), nullable=True), + sa.Column("upvotes", sa.Integer(), nullable=True), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column("location", sa.String(), nullable=True), + sa.Column("action_plan", backend.models.JSONEncodedDict(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("issues", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_issues_category"), ["category"], unique=False) + batch_op.create_index(batch_op.f("ix_issues_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_issues_id"), ["id"], unique=False) + batch_op.create_index(batch_op.f("ix_issues_reference_id"), ["reference_id"], unique=True) + batch_op.create_index(batch_op.f("ix_issues_source"), ["source"], unique=False) + batch_op.create_index(batch_op.f("ix_issues_status"), ["status"], unique=False) + batch_op.create_index( + "ix_issues_status_lat_lon", ["status", "latitude", "longitude"], unique=False + ) + batch_op.create_index(batch_op.f("ix_issues_upvotes"), ["upvotes"], unique=False) + batch_op.create_index(batch_op.f("ix_issues_user_email"), ["user_email"], unique=False) + + op.create_table( + "jurisdictions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "level", + sa.Enum("LOCAL", "DISTRICT", "STATE", "NATIONAL", name="jurisdictionlevel"), + nullable=False, + ), + sa.Column("geographic_coverage", backend.models.JSONEncodedDict(), nullable=False), + sa.Column("responsible_authority", sa.String(), nullable=False), + sa.Column("default_sla_hours", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("jurisdictions", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_jurisdictions_id"), ["id"], unique=False) + batch_op.create_index(batch_op.f("ix_jurisdictions_level"), ["level"], unique=False) + + op.create_table( + "sla_configs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "severity", + sa.Enum("LOW", "MEDIUM", "HIGH", "CRITICAL", name="severitylevel"), + nullable=False, + ), + sa.Column( + "jurisdiction_level", + sa.Enum("LOCAL", "DISTRICT", "STATE", "NATIONAL", name="jurisdictionlevel"), + nullable=False, + ), + sa.Column("department", sa.String(), nullable=False), + sa.Column("sla_hours", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("sla_configs", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_sla_configs_department"), ["department"], unique=False) + batch_op.create_index(batch_op.f("ix_sla_configs_id"), ["id"], unique=False) + batch_op.create_index( + batch_op.f("ix_sla_configs_jurisdiction_level"), ["jurisdiction_level"], unique=False + ) + batch_op.create_index(batch_op.f("ix_sla_configs_severity"), ["severity"], unique=False) + + op.create_table( + "grievances", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("unique_id", sa.String(), nullable=True), + sa.Column("category", sa.String(), nullable=False), + sa.Column( + "severity", + sa.Enum("LOW", "MEDIUM", "HIGH", "CRITICAL", name="severitylevel"), + nullable=False, + ), + sa.Column("pincode", sa.String(), nullable=True), + sa.Column("city", sa.String(), nullable=True), + sa.Column("district", sa.String(), nullable=True), + sa.Column("state", sa.String(), nullable=True), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column("address", sa.String(), nullable=True), + sa.Column("current_jurisdiction_id", sa.Integer(), nullable=False), + sa.Column("assigned_authority", sa.String(), nullable=False), + sa.Column("sla_deadline", sa.DateTime(), nullable=False), + sa.Column( + "status", + sa.Enum("OPEN", "IN_PROGRESS", "ESCALATED", "RESOLVED", name="grievancestatus"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["current_jurisdiction_id"], + ["jurisdictions.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("grievances", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_grievances_category"), ["category"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_id"), ["id"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_latitude"), ["latitude"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_longitude"), ["longitude"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_severity"), ["severity"], unique=False) + batch_op.create_index(batch_op.f("ix_grievances_status"), ["status"], unique=False) + batch_op.create_index( + "ix_grievances_status_jurisdiction", ["status", "current_jurisdiction_id"], unique=False + ) + batch_op.create_index( + "ix_grievances_status_lat_lon", ["status", "latitude", "longitude"], unique=False + ) + batch_op.create_index(batch_op.f("ix_grievances_unique_id"), ["unique_id"], unique=True) + + op.create_table( + "escalation_audits", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("grievance_id", sa.Integer(), nullable=False), + sa.Column("previous_authority", sa.String(), nullable=False), + sa.Column("new_authority", sa.String(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=True), + sa.Column( + "reason", + sa.Enum("SLA_BREACH", "SEVERITY_UPGRADE", "MANUAL", name="escalationreason"), + nullable=False, + ), + sa.Column("notes", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["grievance_id"], + ["grievances.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("escalation_audits", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_escalation_audits_id"), ["id"], unique=False) + batch_op.create_index( + batch_op.f("ix_escalation_audits_timestamp"), ["timestamp"], unique=False + ) + + op.create_table( + "grievance_followers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("grievance_id", sa.Integer(), nullable=False), + sa.Column("user_email", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("integrity_hash", sa.String(), nullable=False), + sa.Column("previous_integrity_hash", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["grievance_id"], + ["grievances.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("grievance_followers", schema=None) as batch_op: + batch_op.create_index( + "ix_follower_grievance_email", ["grievance_id", "user_email"], unique=True + ) + batch_op.create_index( + batch_op.f("ix_grievance_followers_created_at"), ["created_at"], unique=False + ) + batch_op.create_index(batch_op.f("ix_grievance_followers_id"), ["id"], unique=False) + batch_op.create_index( + batch_op.f("ix_grievance_followers_integrity_hash"), ["integrity_hash"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_grievance_followers_user_email"), ["user_email"], unique=False + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("grievance_followers", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_grievance_followers_user_email")) + batch_op.drop_index(batch_op.f("ix_grievance_followers_integrity_hash")) + batch_op.drop_index(batch_op.f("ix_grievance_followers_id")) + batch_op.drop_index(batch_op.f("ix_grievance_followers_created_at")) + batch_op.drop_index("ix_follower_grievance_email") + + op.drop_table("grievance_followers") + with op.batch_alter_table("escalation_audits", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_escalation_audits_timestamp")) + batch_op.drop_index(batch_op.f("ix_escalation_audits_id")) + + op.drop_table("escalation_audits") + with op.batch_alter_table("grievances", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_grievances_unique_id")) + batch_op.drop_index("ix_grievances_status_lat_lon") + batch_op.drop_index("ix_grievances_status_jurisdiction") + batch_op.drop_index(batch_op.f("ix_grievances_status")) + batch_op.drop_index(batch_op.f("ix_grievances_severity")) + batch_op.drop_index(batch_op.f("ix_grievances_longitude")) + batch_op.drop_index(batch_op.f("ix_grievances_latitude")) + batch_op.drop_index(batch_op.f("ix_grievances_id")) + batch_op.drop_index(batch_op.f("ix_grievances_created_at")) + batch_op.drop_index(batch_op.f("ix_grievances_category")) + + op.drop_table("grievances") + with op.batch_alter_table("sla_configs", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_sla_configs_severity")) + batch_op.drop_index(batch_op.f("ix_sla_configs_jurisdiction_level")) + batch_op.drop_index(batch_op.f("ix_sla_configs_id")) + batch_op.drop_index(batch_op.f("ix_sla_configs_department")) + + op.drop_table("sla_configs") + with op.batch_alter_table("jurisdictions", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_jurisdictions_level")) + batch_op.drop_index(batch_op.f("ix_jurisdictions_id")) + + op.drop_table("jurisdictions") + with op.batch_alter_table("issues", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_issues_user_email")) + batch_op.drop_index(batch_op.f("ix_issues_upvotes")) + batch_op.drop_index("ix_issues_status_lat_lon") + batch_op.drop_index(batch_op.f("ix_issues_status")) + batch_op.drop_index(batch_op.f("ix_issues_source")) + batch_op.drop_index(batch_op.f("ix_issues_reference_id")) + batch_op.drop_index(batch_op.f("ix_issues_id")) + batch_op.drop_index(batch_op.f("ix_issues_created_at")) + batch_op.drop_index(batch_op.f("ix_issues_category")) + + op.drop_table("issues") + # ### end Alembic commands ### diff --git a/backend/mock_services.py b/backend/mock_services.py index eaeb9446..833e4145 100644 --- a/backend/mock_services.py +++ b/backend/mock_services.py @@ -1,12 +1,13 @@ """ Mock implementations of AI service interfaces for testing and development. """ -from typing import Dict, Optional + import asyncio from backend.ai_interfaces import ActionPlanService, ChatService, MLASummaryService from backend.ai_service import build_x_post + class MockActionPlanService(ActionPlanService): """Mock implementation that returns predefined responses.""" @@ -14,9 +15,9 @@ async def generate_action_plan( self, issue_description: str, category: str, - language: str = 'en', - image_path: Optional[str] = None - ) -> Dict[str, str]: + language: str = "en", + image_path: str | None = None, + ) -> dict[str, str]: # Simulate async operation await asyncio.sleep(0.1) return { @@ -44,7 +45,7 @@ async def generate_mla_summary( district: str, assembly_constituency: str, mla_name: str, - issue_category: Optional[str] = None + issue_category: str | None = None, ) -> str: # Simulate async operation await asyncio.sleep(0.1) diff --git a/backend/models.py b/backend/models.py index d60cf281..9f9c4b14 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,12 +1,27 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text, Enum, Float, ForeignKey, Index, TypeDecorator -from sqlalchemy.orm import relationship -from database import Base import datetime import enum import json +from sqlalchemy import ( + Column, + DateTime, + Enum, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + TypeDecorator, +) +from sqlalchemy.orm import relationship + +from backend.database import Base + + class JSONEncodedDict(TypeDecorator): """Represents an immutable structure as a json-encoded string.""" + impl = Text cache_ok = True @@ -20,41 +35,49 @@ def process_result_value(self, value, dialect): value = json.loads(value) return value + class JurisdictionLevel(enum.Enum): LOCAL = "local" DISTRICT = "district" STATE = "state" NATIONAL = "national" + class SeverityLevel(enum.Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" + class GrievanceStatus(enum.Enum): OPEN = "open" IN_PROGRESS = "in_progress" ESCALATED = "escalated" RESOLVED = "resolved" + class EscalationReason(enum.Enum): SLA_BREACH = "sla_breach" SEVERITY_UPGRADE = "severity_upgrade" MANUAL = "manual" + class Jurisdiction(Base): __tablename__ = "jurisdictions" id = Column(Integer, primary_key=True, index=True) level = Column(Enum(JurisdictionLevel), nullable=False, index=True) - geographic_coverage = Column(JSONEncodedDict, nullable=False) # e.g., {"states": ["Maharashtra"], "districts": ["Mumbai"]} + geographic_coverage = Column( + JSONEncodedDict, nullable=False + ) # e.g., {"states": ["Maharashtra"], "districts": ["Mumbai"]} responsible_authority = Column(String, nullable=False) # Department or authority name default_sla_hours = Column(Integer, nullable=False) # Default SLA in hours # Relationships grievances = relationship("Grievance", back_populates="jurisdiction") + class Grievance(Base): __tablename__ = "grievances" __table_args__ = ( @@ -77,14 +100,19 @@ class Grievance(Base): assigned_authority = Column(String, nullable=False) sla_deadline = Column(DateTime, nullable=False) status = Column(Enum(GrievanceStatus), default=GrievanceStatus.OPEN, index=True) - created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc), index=True) - updated_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc), onupdate=lambda: datetime.datetime.now(datetime.timezone.utc)) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC), index=True) + updated_at = Column( + DateTime, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ) resolved_at = Column(DateTime, nullable=True) # Relationships jurisdiction = relationship("Jurisdiction", back_populates="grievances") audit_logs = relationship("EscalationAudit", back_populates="grievance") + class SLAConfig(Base): __tablename__ = "sla_configs" @@ -94,6 +122,7 @@ class SLAConfig(Base): department = Column(String, nullable=False, index=True) # Category/department sla_hours = Column(Integer, nullable=False) + class EscalationAudit(Base): __tablename__ = "escalation_audits" @@ -101,21 +130,22 @@ class EscalationAudit(Base): grievance_id = Column(Integer, ForeignKey("grievances.id"), nullable=False) previous_authority = Column(String, nullable=False) new_authority = Column(String, nullable=False) - timestamp = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc), index=True) + timestamp = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC), index=True) reason = Column(Enum(EscalationReason), nullable=False) notes = Column(Text, nullable=True) # Additional context # Relationships grievance = relationship("Grievance", back_populates="audit_logs") + class Issue(Base): __tablename__ = "issues" - __table_args__ = ( - Index("ix_issues_status_lat_lon", "status", "latitude", "longitude"), - ) + __table_args__ = (Index("ix_issues_status_lat_lon", "status", "latitude", "longitude"),) id = Column(Integer, primary_key=True, index=True) - reference_id = Column(String, unique=True, index=True) # Secure reference for government updates + reference_id = Column( + String, unique=True, index=True + ) # Secure reference for government updates description = Column(String) category = Column(String, index=True) image_path = Column(String) @@ -127,19 +157,25 @@ class Issue(Base): latitude = Column(Float, nullable=True) longitude = Column(Float, nullable=True) location = Column(String, nullable=True) - action_plan = Column(Text, nullable=True) + # JSONEncodedDict is declared in this module for exactly this purpose but + # was never applied here, so plans round-tripped as raw JSON strings and + # every reader had to decode them by hand. The underlying storage is + # still Text, so existing rows are unaffected. + action_plan = Column(JSONEncodedDict, nullable=True) + class GrievanceFollower(Base): """ Tracks users following a grievance with blockchain-style integrity hashing. Optimized for O(1) integrity verification using hash chaining. """ + __tablename__ = "grievance_followers" id = Column(Integer, primary_key=True, index=True) grievance_id = Column(Integer, ForeignKey("grievances.id"), nullable=False) user_email = Column(String, nullable=False, index=True) - created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.timezone.utc), index=True) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC), index=True) # Blockchain-style integrity fields integrity_hash = Column(String, nullable=False, index=True) diff --git a/backend/pothole_detection.py b/backend/pothole_detection.py index 15f89da0..adc6965a 100644 --- a/backend/pothole_detection.py +++ b/backend/pothole_detection.py @@ -1,38 +1,41 @@ import logging import threading -from typing import Optional, Any +from typing import Any -from backend.exceptions import ModelLoadException, DetectionException +from backend.exceptions import DetectionException, ModelLoadException # Configure logging logger = logging.getLogger(__name__) # Thread-safe singleton pattern for model loading # This prevents race conditions when multiple threads try to load the model simultaneously -_model: Optional[Any] = None +_model: Any | None = None _model_lock: threading.Lock = threading.Lock() -_model_loading_error: Optional[Exception] = None +_model_loading_error: Exception | None = None _model_initialized: bool = False + def is_model_available(): """ Checks if the model dependencies are available. """ try: - import ultralyticsplus + import ultralyticsplus # noqa: F401 - imported only to probe availability + return True except ImportError: return False + def load_model(): """ Loads the YOLO model lazily. The model file will be downloaded on the first call if not cached. This prevents blocking the application startup. - + Returns: The loaded YOLO model instance. - + Raises: Exception: If model loading fails. """ @@ -41,13 +44,13 @@ def load_model(): # Move import here to prevent blocking startup with heavy imports/checks from ultralyticsplus import YOLO - model = YOLO('keremberke/yolov8n-pothole-segmentation') + model = YOLO("keremberke/yolov8n-pothole-segmentation") # set model parameters - model.overrides['conf'] = 0.25 # NMS confidence threshold - model.overrides['iou'] = 0.45 # NMS IoU threshold - model.overrides['agnostic_nms'] = False # NMS class-agnostic - model.overrides['max_det'] = 1000 # maximum number of detections per image + model.overrides["conf"] = 0.25 # NMS confidence threshold + model.overrides["iou"] = 0.45 # NMS IoU threshold + model.overrides["agnostic_nms"] = False # NMS class-agnostic + model.overrides["max_det"] = 1000 # maximum number of detections per image logger.info("Model loaded successfully.") return model @@ -62,30 +65,30 @@ def load_model(): def get_model(): """ Thread-safe singleton accessor for the pothole detection model. - + Uses double-checked locking pattern to ensure: 1. Only one model instance is ever created 2. Concurrent requests don't trigger multiple model loads 3. Minimal lock contention after initialization - + Returns: The loaded YOLO model instance. - + Raises: Exception: If model loading previously failed or fails on this attempt. - + Thread Safety: This function is thread-safe and can be called from multiple threads simultaneously without causing race conditions or redundant model loads. """ global _model, _model_initialized, _model_loading_error - + # First check (without lock) - fast path for already initialized model if _model_initialized: if _model_loading_error is not None: raise _model_loading_error return _model - + # Acquire lock for thread-safe initialization with _model_lock: # Second check (with lock) - prevent multiple initializations @@ -94,7 +97,7 @@ def get_model(): if _model_loading_error is not None: raise _model_loading_error return _model - + try: logger.info("Initializing model (thread-safe singleton)...") _model = load_model() @@ -106,30 +109,33 @@ def get_model(): _model_loading_error = e _model_initialized = True # Mark as initialized (even though it failed) logger.error(f"Model initialization failed: {e}") - raise ModelLoadException("keremberke/yolov8n-pothole-segmentation", details={"error": str(e)}) from e + raise ModelLoadException( + "keremberke/yolov8n-pothole-segmentation", details={"error": str(e)} + ) from e def reset_model(): """ Resets the model singleton state. Primarily for testing purposes. - + Warning: This function should only be used in testing scenarios. Using it in production while requests are being processed could lead to race conditions. - + Thread Safety: This function is thread-safe but should be used with caution in multi-threaded environments. """ global _model, _model_initialized, _model_loading_error - + with _model_lock: _model = None _model_initialized = False _model_loading_error = None logger.info("Model singleton state has been reset.") + def validate_image_for_processing(image): """ Validates that the image is a valid PIL Image and can be processed. @@ -140,7 +146,10 @@ def validate_image_for_processing(image): return True except Exception as e: logger.error(f"Image validation failed: {e}") - raise DetectionException("Invalid image content for pothole detection", "pothole", details={"error": str(e)}) from e + raise DetectionException( + "Invalid image content for pothole detection", "pothole", details={"error": str(e)} + ) from e + def detect_potholes(image_source): """ @@ -165,12 +174,12 @@ def detect_potholes(image_source): results = model.predict(image_source, stream=False) # observe results - result = results[0] # Single image + result = results[0] # Single image detections = [] - if hasattr(result, 'boxes'): - for i, box in enumerate(result.boxes): + if hasattr(result, "boxes"): + for box in result.boxes: # box.xyxy is [x1, y1, x2, y2] tensor # Convert to list coords = box.xyxy[0].cpu().numpy().tolist() @@ -178,13 +187,17 @@ def detect_potholes(image_source): cls_id = int(box.cls[0].cpu().numpy()) label = result.names[cls_id] - detections.append({ - "box": coords, # [x1, y1, x2, y2] - "confidence": conf, - "label": label - }) + detections.append( + { + "box": coords, # [x1, y1, x2, y2] + "confidence": conf, + "label": label, + } + ) return detections except Exception as e: logger.error(f"Pothole detection failed: {e}") - raise DetectionException("Failed to detect potholes in image", "pothole", details={"error": str(e)}) from e + raise DetectionException( + "Failed to detect potholes in image", "pothole", details={"error": str(e)} + ) from e diff --git a/backend/requirements.in b/backend/requirements.in new file mode 100644 index 00000000..ec05ed92 --- /dev/null +++ b/backend/requirements.in @@ -0,0 +1,22 @@ +fastapi +uvicorn +python-dotenv +sqlalchemy +python-telegram-bot +google-generativeai +python-multipart +psycopg2-binary +async-lru +huggingface-hub +httpx +python-magic +pywebpush +Pillow +firebase-functions +firebase-admin +a2wsgi +# Spatial deduplication dependencies +scikit-learn +numpy +slowapi +alembic diff --git a/backend/requirements.txt b/backend/requirements.txt index e697b726..506a5fe9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,20 +1,340 @@ -fastapi -uvicorn -python-dotenv -sqlalchemy -python-telegram-bot -google-generativeai -python-multipart -psycopg2-binary -async-lru -huggingface-hub -httpx -python-magic -pywebpush -Pillow -firebase-functions -firebase-admin -a2wsgi -# Spatial deduplication dependencies -scikit-learn -numpy +# Locked with: uv pip compile backend/requirements.in --python-version 3.12 --python-platform linux -o backend/requirements.txt +# Do not edit by hand. Edit backend/requirements.in and re-run the command above. +a2wsgi==1.10.10 + # via -r backend/requirements.in +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via pywebpush +aiosignal==1.4.0 + # via aiohttp +alembic==1.19.1 + # via -r backend/requirements.in +annotated-doc==0.0.5 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.14.2 + # via + # httpx + # starlette +async-lru==2.3.0 + # via -r backend/requirements.in +attrs==26.1.0 + # via aiohttp +blinker==1.9.0 + # via flask +cachecontrol==0.14.4 + # via firebase-admin +certifi==2026.7.22 + # via + # httpcore + # httpx + # requests +cffi==2.1.1 + # via cryptography +charset-normalizer==3.5.1 + # via requests +click==8.4.2 + # via + # flask + # functions-framework + # huggingface-hub + # uvicorn +cloudevents==1.12.0 + # via + # firebase-functions + # functions-framework +cryptography==50.0.0 + # via + # google-auth + # http-ece + # py-vapid + # pyjwt + # pywebpush +deprecated==1.3.1 + # via limits +deprecation==2.1.0 + # via cloudevents +fastapi==0.141.1 + # via -r backend/requirements.in +filelock==3.32.3 + # via huggingface-hub +firebase-admin==7.5.0 + # via + # -r backend/requirements.in + # firebase-functions +firebase-functions==0.6.0 + # via -r backend/requirements.in +flask==3.1.3 + # via + # firebase-functions + # flask-cors + # functions-framework +flask-cors==6.0.5 + # via firebase-functions +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 + # via huggingface-hub +functions-framework==3.10.2 + # via firebase-functions +google-ai-generativelanguage==0.6.15 + # via google-generativeai +google-api-core==2.33.0 + # via + # firebase-admin + # google-ai-generativelanguage + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage + # google-generativeai +google-api-python-client==2.198.0 + # via google-generativeai +google-auth==2.56.3 + # via + # google-ai-generativelanguage + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage + # google-generativeai +google-auth-httplib2==0.4.1 + # via google-api-python-client +google-cloud-core==2.6.1 + # via + # google-cloud-firestore + # google-cloud-storage +google-cloud-firestore==2.27.0 + # via + # firebase-admin + # firebase-functions +google-cloud-storage==3.13.1 + # via firebase-admin +google-crc32c==1.8.0 + # via + # google-cloud-storage + # google-resumable-media +google-events==0.5.0 + # via firebase-functions +google-generativeai==0.8.6 + # via -r backend/requirements.in +google-resumable-media==2.10.1 + # via google-cloud-storage +googleapis-common-protos==1.75.0 + # via + # google-api-core + # grpcio-status +greenlet==3.5.5 + # via sqlalchemy +grpcio==1.83.0 + # via + # google-api-core + # google-cloud-firestore + # grpcio-status +grpcio-status==1.71.2 + # via google-api-core +gunicorn==26.1.0 + # via + # functions-framework + # uvicorn-worker +h11==0.16.0 + # via + # httpcore + # uvicorn +h2==4.4.1 + # via httpx +hf-xet==1.6.0 + # via huggingface-hub +hpack==4.2.0 + # via h2 +http-ece==1.2.1 + # via pywebpush +httpcore==1.0.9 + # via httpx +httplib2==0.32.0 + # via + # google-api-python-client + # google-auth-httplib2 +httpx==0.28.1 + # via + # -r backend/requirements.in + # firebase-admin + # huggingface-hub + # python-telegram-bot +huggingface-hub==1.28.0 + # via -r backend/requirements.in +hyperframe==6.1.0 + # via h2 +idna==3.19 + # via + # anyio + # httpx + # requests + # yarl +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +joblib==1.5.3 + # via scikit-learn +limits==5.8.0 + # via slowapi +mako==1.4.1 + # via alembic +markupsafe==3.0.3 + # via + # flask + # jinja2 + # mako + # werkzeug +msgpack==1.2.1 + # via cachecontrol +multidict==6.7.1 + # via + # aiohttp + # yarl +narwhals==2.24.0 + # via scikit-learn +numpy==2.5.2 + # via + # -r backend/requirements.in + # scikit-learn + # scipy +packaging==26.3 + # via + # deprecation + # huggingface-hub + # limits +pillow==12.3.0 + # via -r backend/requirements.in +propcache==0.5.2 + # via + # aiohttp + # yarl +proto-plus==1.28.2 + # via + # google-ai-generativelanguage + # google-api-core + # google-cloud-firestore + # google-events +protobuf==5.29.6 + # via + # google-ai-generativelanguage + # google-api-core + # google-cloud-firestore + # google-events + # google-generativeai + # googleapis-common-protos + # grpcio-status + # proto-plus +psycopg2-binary==2.9.12 + # via -r backend/requirements.in +py-vapid==1.9.4 + # via pywebpush +pyasn1==0.6.4 + # via pyasn1-modules +pyasn1-modules==0.4.2 + # via google-auth +pycparser==3.0 + # via cffi +pydantic==2.13.4 + # via + # fastapi + # google-generativeai +pydantic-core==2.46.4 + # via pydantic +pyjwt==2.13.0 + # via + # firebase-admin + # firebase-functions +pyparsing==3.3.2 + # via httplib2 +python-dotenv==1.2.3 + # via -r backend/requirements.in +python-magic==0.4.27 + # via -r backend/requirements.in +python-multipart==0.0.32 + # via -r backend/requirements.in +python-telegram-bot==22.8 + # via -r backend/requirements.in +pywebpush==2.4.0 + # via -r backend/requirements.in +pyyaml==6.0.3 + # via + # firebase-functions + # huggingface-hub +requests==2.34.2 + # via + # cachecontrol + # google-api-core + # google-cloud-storage + # pywebpush +scikit-learn==1.9.0 + # via -r backend/requirements.in +scipy==1.18.0 + # via scikit-learn +slowapi==0.1.10 + # via -r backend/requirements.in +sqlalchemy==2.0.52 + # via + # -r backend/requirements.in + # alembic +starlette==1.6.0 + # via + # fastapi + # functions-framework +threadpoolctl==3.6.0 + # via scikit-learn +tqdm==4.70.0 + # via + # google-generativeai + # huggingface-hub +typing-extensions==4.16.0 + # via + # aiohttp + # aiosignal + # alembic + # anyio + # fastapi + # firebase-functions + # google-generativeai + # grpcio + # huggingface-hub + # limits + # pydantic + # pydantic-core + # sqlalchemy + # starlette + # typing-inspection +typing-inspection==0.4.4 + # via + # fastapi + # pydantic +uritemplate==4.2.0 + # via google-api-python-client +urllib3==2.7.0 + # via requests +uvicorn==0.52.4 + # via + # -r backend/requirements.in + # functions-framework + # uvicorn-worker +uvicorn-worker==0.4.0 + # via functions-framework +watchdog==6.0.0 + # via functions-framework +werkzeug==3.1.8 + # via + # flask + # flask-cors + # functions-framework +wrapt==2.3.0 + # via deprecated +yarl==1.24.5 + # via aiohttp diff --git a/backend/responsibility_mapper.py b/backend/responsibility_mapper.py index 0deee25f..3be395b4 100644 --- a/backend/responsibility_mapper.py +++ b/backend/responsibility_mapper.py @@ -2,15 +2,19 @@ import os from functools import lru_cache + @lru_cache(maxsize=1) def _load_responsibility_map(): - file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json") + file_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json" + ) try: - with open(file_path, "r") as f: + with open(file_path) as f: return json.load(f) except FileNotFoundError: return {} + def get_responsible_authority(): """ Returns the responsibility map data. diff --git a/backend/retry_utils.py b/backend/retry_utils.py index 46585f0f..fe0f770a 100644 --- a/backend/retry_utils.py +++ b/backend/retry_utils.py @@ -4,15 +4,17 @@ Provides decorators and utilities for handling transient failures in AI API calls such as rate limits, network issues, and temporary service unavailability. """ + import asyncio import functools import logging -from typing import TypeVar, Callable, Optional, Tuple, Type import time +from collections.abc import Callable +from typing import TypeVar logger = logging.getLogger(__name__) -T = TypeVar('T') +T = TypeVar("T") def exponential_backoff_retry( @@ -20,47 +22,48 @@ def exponential_backoff_retry( base_delay: float = 1.0, max_delay: float = 60.0, exponential_base: float = 2.0, - exceptions: Tuple[Type[Exception], ...] = (Exception,) + exceptions: tuple[type[Exception], ...] = (Exception,), ): """ Decorator for async functions that implements exponential backoff retry logic. - + Args: max_retries: Maximum number of retry attempts (default: 3) base_delay: Initial delay in seconds between retries (default: 1.0) max_delay: Maximum delay in seconds between retries (default: 60.0) exponential_base: Base for exponential backoff calculation (default: 2.0) exceptions: Tuple of exception types to catch and retry (default: all exceptions) - + Returns: Decorated async function with retry logic - + Example: @exponential_backoff_retry(max_retries=3, base_delay=1.0) async def call_ai_api(): # API call that may fail transiently pass """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: @functools.wraps(func) async def wrapper(*args, **kwargs) -> T: last_exception = None - + for attempt in range(max_retries + 1): try: result = await func(*args, **kwargs) - + # Log successful retry if this wasn't the first attempt if attempt > 0: logger.info( f"{func.__name__} succeeded on attempt {attempt + 1}/{max_retries + 1}" ) - + return result - + except exceptions as e: last_exception = e - + # If this was the last attempt, don't retry if attempt == max_retries: logger.error( @@ -68,22 +71,23 @@ async def wrapper(*args, **kwargs) -> T: f"Last error: {str(e)}" ) raise - + # Calculate delay with exponential backoff - delay = min(base_delay * (exponential_base ** attempt), max_delay) - + delay = min(base_delay * (exponential_base**attempt), max_delay) + logger.warning( f"{func.__name__} failed on attempt {attempt + 1}/{max_retries + 1}. " f"Error: {str(e)}. Retrying in {delay:.2f}s..." ) - + await asyncio.sleep(delay) - + # This should never be reached, but just in case if last_exception: raise last_exception - + return wrapper + return decorator @@ -92,41 +96,42 @@ def sync_exponential_backoff_retry( base_delay: float = 1.0, max_delay: float = 60.0, exponential_base: float = 2.0, - exceptions: Tuple[Type[Exception], ...] = (Exception,) + exceptions: tuple[type[Exception], ...] = (Exception,), ): """ Decorator for synchronous functions that implements exponential backoff retry logic. - + Args: max_retries: Maximum number of retry attempts (default: 3) base_delay: Initial delay in seconds between retries (default: 1.0) max_delay: Maximum delay in seconds between retries (default: 60.0) exponential_base: Base for exponential backoff calculation (default: 2.0) exceptions: Tuple of exception types to catch and retry (default: all exceptions) - + Returns: Decorated sync function with retry logic """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: @functools.wraps(func) def wrapper(*args, **kwargs) -> T: last_exception = None - + for attempt in range(max_retries + 1): try: result = func(*args, **kwargs) - + # Log successful retry if this wasn't the first attempt if attempt > 0: logger.info( f"{func.__name__} succeeded on attempt {attempt + 1}/{max_retries + 1}" ) - + return result - + except exceptions as e: last_exception = e - + # If this was the last attempt, don't retry if attempt == max_retries: logger.error( @@ -134,20 +139,21 @@ def wrapper(*args, **kwargs) -> T: f"Last error: {str(e)}" ) raise - + # Calculate delay with exponential backoff - delay = min(base_delay * (exponential_base ** attempt), max_delay) - + delay = min(base_delay * (exponential_base**attempt), max_delay) + logger.warning( f"{func.__name__} failed on attempt {attempt + 1}/{max_retries + 1}. " f"Error: {str(e)}. Retrying in {delay:.2f}s..." ) - + time.sleep(delay) - + # This should never be reached, but just in case if last_exception: raise last_exception - + return wrapper + return decorator diff --git a/backend/routing_service.py b/backend/routing_service.py index 289f2a2c..836dbb51 100644 --- a/backend/routing_service.py +++ b/backend/routing_service.py @@ -3,11 +3,13 @@ Handles dynamic routing and authority assignment based on geography and department. """ -import json -from typing import Optional, Dict, Any +from typing import Any + from sqlalchemy.orm import Session -from backend.models import Jurisdiction, JurisdictionLevel, Grievance + from backend.database import SessionLocal +from backend.models import Jurisdiction, JurisdictionLevel + class RoutingService: """ @@ -15,7 +17,7 @@ class RoutingService: Uses configurable rules to route grievances to appropriate authorities. """ - def __init__(self, rules_config: Dict[str, Any]): + def __init__(self, rules_config: dict[str, Any]): """ Initialize with routing rules configuration. @@ -24,7 +26,9 @@ def __init__(self, rules_config: Dict[str, Any]): """ self.rules_config = rules_config - def determine_initial_jurisdiction(self, grievance_data: Dict[str, Any], db: Session) -> Optional[Jurisdiction]: + def determine_initial_jurisdiction( + self, grievance_data: dict[str, Any], db: Session + ) -> Jurisdiction | None: """ Determine the initial jurisdiction for a grievance based on geography and department. @@ -40,38 +44,35 @@ def determine_initial_jurisdiction(self, grievance_data: Dict[str, Any], db: Ses Returns: Jurisdiction object or None if no match found """ - category = grievance_data.get('category') - pincode = grievance_data.get('pincode') - city = grievance_data.get('city') - district = grievance_data.get('district') - state = grievance_data.get('state') + category = grievance_data.get("category") + # Routing keys off category and the administrative fields below; + # pincode is carried on the grievance but is not a routing input. + city = grievance_data.get("city") + district = grievance_data.get("district") + state = grievance_data.get("state") # Get routing rules for the category - category_rules = self.rules_config.get('categories', {}).get(category, {}) - geographic_rules = self.rules_config.get('geographic_rules', {}) + category_rules = self.rules_config.get("categories", {}).get(category, {}) + geographic_rules = self.rules_config.get("geographic_rules", {}) # Check for state-level rules - if state and state in geographic_rules.get('states', {}): - state_config = geographic_rules['states'][state] - if category in state_config.get('departments', []): + if state and state in geographic_rules.get("states", {}): + state_config = geographic_rules["states"][state] + if category in state_config.get("departments", []): jurisdiction_level = JurisdictionLevel.STATE else: - jurisdiction_level = state_config.get('default_level', JurisdictionLevel.DISTRICT) + jurisdiction_level = state_config.get("default_level", JurisdictionLevel.DISTRICT) else: # Default to district level for known states, local for others jurisdiction_level = JurisdictionLevel.DISTRICT if state else JurisdictionLevel.LOCAL # Override based on category-specific rules - if 'jurisdiction_level' in category_rules: - jurisdiction_level = JurisdictionLevel(category_rules['jurisdiction_level']) + if "jurisdiction_level" in category_rules: + jurisdiction_level = JurisdictionLevel(category_rules["jurisdiction_level"]) # Find the specific jurisdiction jurisdiction = self._find_jurisdiction( - jurisdiction_level=jurisdiction_level, - state=state, - district=district, - city=city, - db=db + jurisdiction_level=jurisdiction_level, state=state, district=district, city=city, db=db ) return jurisdiction @@ -88,16 +89,21 @@ def assign_authority(self, jurisdiction: Jurisdiction, category: str) -> str: Authority name """ # Check category-specific authority overrides - category_rules = self.rules_config.get('categories', {}).get(category, {}) - if 'authority' in category_rules: - return category_rules['authority'] + category_rules = self.rules_config.get("categories", {}).get(category, {}) + if "authority" in category_rules: + return category_rules["authority"] # Use jurisdiction's default authority return jurisdiction.responsible_authority - def _find_jurisdiction(self, jurisdiction_level: JurisdictionLevel, state: Optional[str] = None, - district: Optional[str] = None, city: Optional[str] = None, - db: Session = None) -> Optional[Jurisdiction]: + def _find_jurisdiction( + self, + jurisdiction_level: JurisdictionLevel, + state: str | None = None, + district: str | None = None, + city: str | None = None, + db: Session = None, + ) -> Jurisdiction | None: """ Find the most specific jurisdiction matching the given criteria. @@ -128,11 +134,11 @@ def _find_jurisdiction(self, jurisdiction_level: JurisdictionLevel, state: Optio coverage = jur.geographic_coverage score = 0 - if state and state in coverage.get('states', []): + if state and state in coverage.get("states", []): score += 3 - if district and district in coverage.get('districts', []): + if district and district in coverage.get("districts", []): score += 2 - if city and city in coverage.get('cities', []): + if city and city in coverage.get("cities", []): score += 1 if score > best_match_score: @@ -145,7 +151,9 @@ def _find_jurisdiction(self, jurisdiction_level: JurisdictionLevel, state: Optio if db is not SessionLocal(): db.close() - def get_next_jurisdiction_level(self, current_level: JurisdictionLevel) -> Optional[JurisdictionLevel]: + def get_next_jurisdiction_level( + self, current_level: JurisdictionLevel + ) -> JurisdictionLevel | None: """ Get the next higher jurisdiction level for escalation. @@ -159,7 +167,7 @@ def get_next_jurisdiction_level(self, current_level: JurisdictionLevel) -> Optio JurisdictionLevel.LOCAL: JurisdictionLevel.DISTRICT, JurisdictionLevel.DISTRICT: JurisdictionLevel.STATE, JurisdictionLevel.STATE: JurisdictionLevel.NATIONAL, - JurisdictionLevel.NATIONAL: None + JurisdictionLevel.NATIONAL: None, } return level_hierarchy.get(current_level) @@ -174,4 +182,4 @@ def can_escalate(self, current_level: JurisdictionLevel) -> bool: Returns: True if escalation is possible """ - return self.get_next_jurisdiction_level(current_level) is not None \ No newline at end of file + return self.get_next_jurisdiction_level(current_level) is not None diff --git a/backend/schemas.py b/backend/schemas.py index 2119ca4c..f088f8e0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, Field, ConfigDict, validator, field_validator -from typing import List, Optional, Any, Dict, Union -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + class IssueCategory(str, Enum): ROAD = "Road" @@ -11,6 +13,7 @@ class IssueCategory(str, Enum): COLLEGE_INFRA = "College Infra" WOMEN_SAFETY = "Women Safety" + class IssueStatus(str, Enum): OPEN = "open" VERIFIED = "verified" @@ -18,76 +21,87 @@ class IssueStatus(str, Enum): IN_PROGRESS = "in_progress" RESOLVED = "resolved" + class ActionPlan(BaseModel): - whatsapp: Optional[str] = Field(None, description="WhatsApp message template") - email_subject: Optional[str] = Field(None, description="Email subject line") - email_body: Optional[str] = Field(None, description="Email body content") - x_post: Optional[str] = Field(None, description="X (Twitter) post content") + whatsapp: str | None = Field(None, description="WhatsApp message template") + email_subject: str | None = Field(None, description="Email subject line") + email_body: str | None = Field(None, description="Email body content") + x_post: str | None = Field(None, description="X (Twitter) post content") + class ChatRequest(BaseModel): query: str = Field(..., min_length=1, max_length=1000, description="User's chat query") - @field_validator('query') + @field_validator("query") @classmethod def validate_query(cls, v): if not v.strip(): - raise ValueError('Query cannot be empty or whitespace only') + raise ValueError("Query cannot be empty or whitespace only") return v.strip() + class ChatResponse(BaseModel): response: str = Field(..., description="AI assistant's response") + class IssueSummaryResponse(BaseModel): id: int = Field(..., description="Unique issue identifier") category: str = Field(..., description="Issue category") description: str = Field(..., description="Issue description") created_at: datetime = Field(..., description="Issue creation timestamp") - image_path: Optional[str] = Field(None, description="Path to uploaded image") + image_path: str | None = Field(None, description="Path to uploaded image") status: str = Field(..., description="Issue status") upvotes: int = Field(0, description="Number of upvotes") - location: Optional[str] = Field(None, description="Location description") - latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate") - longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate") + location: str | None = Field(None, description="Location description") + latitude: float | None = Field(None, ge=-90, le=90, description="Latitude coordinate") + longitude: float | None = Field(None, ge=-180, le=180, description="Longitude coordinate") # action_plan excluded to optimize payload size model_config = ConfigDict(from_attributes=True) + class IssueResponse(IssueSummaryResponse): - action_plan: Optional[Dict[str, Any]] = Field(None, description="Generated action plan") + action_plan: dict[str, Any] | None = Field(None, description="Generated action plan") + class IssueCreateRequest(BaseModel): description: str = Field(..., min_length=10, max_length=1000, description="Issue description") category: IssueCategory = Field(..., description="Issue category") - user_email: Optional[str] = Field(None, description="User's email address") - latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate") - longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate") - location: Optional[str] = Field(None, max_length=200, description="Location description") + user_email: str | None = Field(None, description="User's email address") + latitude: float | None = Field(None, ge=-90, le=90, description="Latitude coordinate") + longitude: float | None = Field(None, ge=-180, le=180, description="Longitude coordinate") + location: str | None = Field(None, max_length=200, description="Location description") - @field_validator('description') + @field_validator("description") @classmethod def validate_description(cls, v): if not v.strip(): - raise ValueError('Description cannot be empty or whitespace only') + raise ValueError("Description cannot be empty or whitespace only") return v.strip() + class IssueCreateResponse(BaseModel): id: int = Field(..., description="Created issue ID") message: str = Field(..., description="Success message") - action_plan: Optional[ActionPlan] = Field(None, description="Generated action plan") + action_plan: ActionPlan | None = Field(None, description="Generated action plan") + class VoteRequest(BaseModel): vote_type: str = Field(..., pattern="^(up|down)$", description="Vote type: 'up' or 'down'") + class VoteResponse(BaseModel): id: int = Field(..., description="Issue ID") upvotes: int = Field(..., description="Updated upvote count") message: str = Field(..., description="Vote confirmation message") + class IssueStatusUpdateRequest(BaseModel): reference_id: str = Field(..., description="Secure reference ID for the issue") status: IssueStatus = Field(..., description="New status for the issue") - assigned_to: Optional[str] = Field(None, description="Government official/department assigned") - notes: Optional[str] = Field(None, description="Additional notes from government") + assigned_to: str | None = Field(None, description="Government official/department assigned") + notes: str | None = Field(None, description="Additional notes from government") + class IssueStatusUpdateResponse(BaseModel): id: int = Field(..., description="Issue ID") @@ -95,71 +109,87 @@ class IssueStatusUpdateResponse(BaseModel): status: IssueStatus = Field(..., description="Updated status") message: str = Field(..., description="Update confirmation message") + class PushSubscriptionRequest(BaseModel): - user_email: Optional[str] = Field(None, description="User email for notifications") + user_email: str | None = Field(None, description="User email for notifications") endpoint: str = Field(..., description="Push service endpoint") p256dh: str = Field(..., description="P-256 DH key") auth: str = Field(..., description="Authentication secret") - issue_id: Optional[int] = Field(None, description="Specific issue to subscribe to") + issue_id: int | None = Field(None, description="Specific issue to subscribe to") + class PushSubscriptionResponse(BaseModel): id: int = Field(..., description="Subscription ID") message: str = Field(..., description="Subscription confirmation") -class DetectionResponse(BaseModel): - detections: List[Dict[str, Any]] = Field(..., description="List of detected objects/items") class DetectionResponse(BaseModel): - detections: List[Dict[str, Any]] = Field(..., description="List of detected objects/items") + detections: list[dict[str, Any]] = Field(..., description="List of detected objects/items") + class VisionAnalysisResponse(BaseModel): description: str = Field(..., description="AI-generated description of the issue") category: str = Field(..., description="Detected issue category") severity: str = Field(..., description="Severity level: Low, Medium, or High") - authority: Optional[str] = Field(None, description="Responsible authority") - action_plan: Optional[str] = Field(None, description="Recommended action plan") + authority: str | None = Field(None, description="Responsible authority") + action_plan: str | None = Field(None, description="Recommended action plan") model_used: str = Field(..., description="Vision model used for analysis") + class UrgencyAnalysisRequest(BaseModel): description: str = Field(..., min_length=10, max_length=1000, description="Issue description") category: IssueCategory = Field(..., description="Issue category") + class UrgencyAnalysisResponse(BaseModel): - urgency_level: str = Field(..., pattern="^(low|medium|high|critical)$", description="Urgency level") + urgency_level: str = Field( + ..., pattern="^(low|medium|high|critical)$", description="Urgency level" + ) reasoning: str = Field(..., description="Explanation for urgency assessment") - recommended_actions: List[str] = Field(..., description="Recommended immediate actions") + recommended_actions: list[str] = Field(..., description="Recommended immediate actions") + class HealthResponse(BaseModel): - status: str = Field(..., pattern="^(healthy|degraded|unhealthy)$", description="Service health status") + status: str = Field( + ..., pattern="^(healthy|degraded|unhealthy)$", description="Service health status" + ) timestamp: datetime = Field(..., description="Health check timestamp") - version: Optional[str] = Field(None, description="API version") - services: Optional[Dict[str, str]] = Field(None, description="Service status details") + version: str | None = Field(None, description="API version") + services: dict[str, str] | None = Field(None, description="Service status details") + class MLStatusResponse(BaseModel): status: str = Field(..., description="ML service status") - models_loaded: List[str] = Field(..., description="List of loaded models") - memory_usage: Optional[Dict[str, Any]] = Field(None, description="Memory usage statistics") + models_loaded: list[str] = Field(..., description="List of loaded models") + memory_usage: dict[str, Any] | None = Field(None, description="Memory usage statistics") + class ResponsibilityMapResponse(BaseModel): - data: Dict[str, Any] = Field(..., description="Responsibility mapping data") + data: dict[str, Any] = Field(..., description="Responsibility mapping data") + class ErrorResponse(BaseModel): error: str = Field(..., description="Error message") error_code: str = Field(..., description="Error code for client handling") - details: Optional[Dict[str, Any]] = Field(None, description="Additional error details") - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Error timestamp") + details: dict[str, Any] | None = Field(None, description="Additional error details") + timestamp: datetime = Field( + default_factory=lambda: datetime.now(UTC), description="Error timestamp" + ) + class SuccessResponse(BaseModel): message: str = Field(..., description="Success message") - data: Optional[Dict[str, Any]] = Field(None, description="Response data") - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Response timestamp") + data: dict[str, Any] | None = Field(None, description="Response data") + timestamp: datetime = Field( + default_factory=lambda: datetime.now(UTC), description="Response timestamp" + ) class StatsResponse(BaseModel): total_issues: int = Field(..., description="Total number of issues reported") resolved_issues: int = Field(..., description="Number of resolved/verified issues") pending_issues: int = Field(..., description="Number of open/assigned/in_progress issues") - issues_by_category: Dict[str, int] = Field(..., description="Count of issues by category") + issues_by_category: dict[str, int] = Field(..., description="Count of issues by category") class NearbyIssueResponse(BaseModel): @@ -176,16 +206,24 @@ class NearbyIssueResponse(BaseModel): class DeduplicationCheckResponse(BaseModel): has_nearby_issues: bool = Field(..., description="Whether nearby issues were found") - nearby_issues: List[NearbyIssueResponse] = Field(default_factory=list, description="List of nearby issues") - recommended_action: str = Field(..., description="Recommended action: 'create_new', 'upvote_existing', 'verify_existing'") + nearby_issues: list[NearbyIssueResponse] = Field( + default_factory=list, description="List of nearby issues" + ) + recommended_action: str = Field( + ..., description="Recommended action: 'create_new', 'upvote_existing', 'verify_existing'" + ) class IssueCreateWithDeduplicationResponse(BaseModel): - id: Optional[int] = Field(None, description="Created issue ID (None if deduplication occurred)") + id: int | None = Field(None, description="Created issue ID (None if deduplication occurred)") message: str = Field(..., description="Response message") - action_plan: Optional[ActionPlan] = Field(None, description="Generated action plan") - deduplication_info: DeduplicationCheckResponse = Field(..., description="Deduplication check results") - linked_issue_id: Optional[int] = Field(None, description="ID of existing issue that was upvoted (if applicable)") + action_plan: ActionPlan | None = Field(None, description="Generated action plan") + deduplication_info: DeduplicationCheckResponse = Field( + ..., description="Deduplication check results" + ) + linked_issue_id: int | None = Field( + None, description="ID of existing issue that was upvoted (if applicable)" + ) class LeaderboardEntry(BaseModel): @@ -194,8 +232,9 @@ class LeaderboardEntry(BaseModel): total_upvotes: int = Field(..., description="Total upvotes received on reports") rank: int = Field(..., description="Rank on the leaderboard") + class LeaderboardResponse(BaseModel): - leaderboard: List[LeaderboardEntry] = Field(..., description="List of top reporters") + leaderboard: list[LeaderboardEntry] = Field(..., description="List of top reporters") # Escalation-related schemas @@ -205,25 +244,31 @@ class EscalationAuditResponse(BaseModel): previous_authority: str = Field(..., description="Previous authority handling the grievance") new_authority: str = Field(..., description="New authority after escalation") timestamp: datetime = Field(..., description="When the escalation occurred") - reason: str = Field(..., description="Reason for escalation (SLA_BREACH, SEVERITY_UPGRADE, MANUAL)") + reason: str = Field( + ..., description="Reason for escalation (SLA_BREACH, SEVERITY_UPGRADE, MANUAL)" + ) + class GrievanceSummaryResponse(BaseModel): id: int = Field(..., description="Grievance ID") unique_id: str = Field(..., description="Unique grievance identifier") category: str = Field(..., description="Issue category") severity: str = Field(..., description="Severity level (LOW, MEDIUM, HIGH, CRITICAL)") - pincode: Optional[str] = Field(None, description="Pincode") - city: Optional[str] = Field(None, description="City") - district: Optional[str] = Field(None, description="District") - state: Optional[str] = Field(None, description="State") + pincode: str | None = Field(None, description="Pincode") + city: str | None = Field(None, description="City") + district: str | None = Field(None, description="District") + state: str | None = Field(None, description="State") current_jurisdiction_id: int = Field(..., description="Current jurisdiction ID") assigned_authority: str = Field(..., description="Currently assigned authority") sla_deadline: datetime = Field(..., description="SLA deadline") status: str = Field(..., description="Current status") created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") - resolved_at: Optional[datetime] = Field(None, description="Resolution timestamp") - escalation_history: List[EscalationAuditResponse] = Field(default_factory=list, description="Escalation history") + resolved_at: datetime | None = Field(None, description="Resolution timestamp") + escalation_history: list[EscalationAuditResponse] = Field( + default_factory=list, description="Escalation history" + ) + class EscalationStatsResponse(BaseModel): total_grievances: int = Field(..., description="Total number of grievances") @@ -232,23 +277,28 @@ class EscalationStatsResponse(BaseModel): resolved_grievances: int = Field(..., description="Number of resolved grievances") escalation_rate: float = Field(..., description="Percentage of grievances that were escalated") + # Blockchain-style follower schemas class FollowerCreateRequest(BaseModel): user_email: str = Field(..., description="User email following the grievance") + class FollowerResponse(BaseModel): id: int = Field(..., description="Follower record ID") grievance_id: int = Field(..., description="Associated grievance ID") user_email: str = Field(..., description="User email") created_at: datetime = Field(..., description="Follow timestamp") integrity_hash: str = Field(..., description="Cryptographic integrity hash") - previous_integrity_hash: Optional[str] = Field(None, description="Hash of the previous follower record") + previous_integrity_hash: str | None = Field( + None, description="Hash of the previous follower record" + ) model_config = ConfigDict(from_attributes=True) + class BlockchainVerificationResponse(BaseModel): is_valid: bool = Field(..., description="Whether the integrity check passed") current_hash: str = Field(..., description="Hash stored in the record") calculated_hash: str = Field(..., description="Re-calculated hash based on record data") - previous_hash: Optional[str] = Field(None, description="Previous hash used for verification") + previous_hash: str | None = Field(None, description="Previous hash used for verification") message: str = Field(..., description="Verification result message") diff --git a/backend/sla_config_service.py b/backend/sla_config_service.py index 0ef9153f..80958165 100644 --- a/backend/sla_config_service.py +++ b/backend/sla_config_service.py @@ -3,10 +3,11 @@ Manages SLA rules and configurations for different scenarios. """ -from typing import Optional from sqlalchemy.orm import Session -from backend.models import SLAConfig, JurisdictionLevel, SeverityLevel + from backend.database import SessionLocal +from backend.models import JurisdictionLevel, SeverityLevel, SLAConfig + class SLAConfigService: """ @@ -22,8 +23,13 @@ def __init__(self, default_sla_hours: int = 48): """ self.default_sla_hours = default_sla_hours - def get_sla_hours(self, severity: SeverityLevel, jurisdiction_level: JurisdictionLevel, - department: str, db: Session = None) -> int: + def get_sla_hours( + self, + severity: SeverityLevel, + jurisdiction_level: JurisdictionLevel, + department: str, + db: Session = None, + ) -> int: """ Get SLA hours for specific combination of severity, jurisdiction, and department. @@ -41,41 +47,57 @@ def get_sla_hours(self, severity: SeverityLevel, jurisdiction_level: Jurisdictio try: # Try to find exact match - sla_config = db.query(SLAConfig).filter( - SLAConfig.severity == severity, - SLAConfig.jurisdiction_level == jurisdiction_level, - SLAConfig.department == department - ).first() + sla_config = ( + db.query(SLAConfig) + .filter( + SLAConfig.severity == severity, + SLAConfig.jurisdiction_level == jurisdiction_level, + SLAConfig.department == department, + ) + .first() + ) if sla_config: return sla_config.sla_hours # Try department and severity only - sla_config = db.query(SLAConfig).filter( - SLAConfig.severity == severity, - SLAConfig.department == department, - SLAConfig.jurisdiction_level.is_(None) - ).first() + sla_config = ( + db.query(SLAConfig) + .filter( + SLAConfig.severity == severity, + SLAConfig.department == department, + SLAConfig.jurisdiction_level.is_(None), + ) + .first() + ) if sla_config: return sla_config.sla_hours # Try severity and jurisdiction only - sla_config = db.query(SLAConfig).filter( - SLAConfig.severity == severity, - SLAConfig.jurisdiction_level == jurisdiction_level, - SLAConfig.department.is_(None) - ).first() + sla_config = ( + db.query(SLAConfig) + .filter( + SLAConfig.severity == severity, + SLAConfig.jurisdiction_level == jurisdiction_level, + SLAConfig.department.is_(None), + ) + .first() + ) if sla_config: return sla_config.sla_hours # Try severity only - sla_config = db.query(SLAConfig).filter( - SLAConfig.severity == severity, - SLAConfig.jurisdiction_level.is_(None), - SLAConfig.department.is_(None) - ).first() + sla_config = ( + db.query(SLAConfig) + .filter( + SLAConfig.severity == severity, + SLAConfig.jurisdiction_level.is_(None), + SLAConfig.department.is_(None), + ) + .first() + ) if sla_config: return sla_config.sla_hours @@ -87,8 +109,14 @@ def get_sla_hours(self, severity: SeverityLevel, jurisdiction_level: Jurisdictio if db is not SessionLocal(): db.close() - def create_sla_config(self, severity: SeverityLevel, jurisdiction_level: JurisdictionLevel, - department: str, sla_hours: int, db: Session = None) -> SLAConfig: + def create_sla_config( + self, + severity: SeverityLevel, + jurisdiction_level: JurisdictionLevel, + department: str, + sla_hours: int, + db: Session = None, + ) -> SLAConfig: """ Create a new SLA configuration. @@ -110,7 +138,7 @@ def create_sla_config(self, severity: SeverityLevel, jurisdiction_level: Jurisdi severity=severity, jurisdiction_level=jurisdiction_level, department=department, - sla_hours=sla_hours + sla_hours=sla_hours, ) db.add(sla_config) @@ -141,4 +169,4 @@ def get_all_sla_configs(self, db: Session = None) -> list[SLAConfig]: finally: if db is not SessionLocal(): - db.close() \ No newline at end of file + db.close() diff --git a/backend/spatial_utils.py b/backend/spatial_utils.py index 213bfeaa..30b1823d 100644 --- a/backend/spatial_utils.py +++ b/backend/spatial_utils.py @@ -1,15 +1,18 @@ """ Spatial utilities for geospatial operations and deduplication. """ + import math -from typing import List, Tuple, Optional -from sklearn.cluster import DBSCAN + import numpy as np +from sklearn.cluster import DBSCAN from backend.models import Issue -def get_bounding_box(lat: float, lon: float, radius_meters: float) -> Tuple[float, float, float, float]: +def get_bounding_box( + lat: float, lon: float, radius_meters: float +) -> tuple[float, float, float, float]: """ Calculate the bounding box coordinates for a given radius. Returns (min_lat, max_lat, min_lon, max_lon). @@ -50,18 +53,15 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl dlambda = math.radians(lon2 - lon1) # Haversine formula - a = math.sin(dphi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2)**2 + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c def find_nearby_issues( - issues: List[Issue], - target_lat: float, - target_lon: float, - radius_meters: float = 50.0 -) -> List[Tuple[Issue, float]]: + issues: list[Issue], target_lat: float, target_lon: float, radius_meters: float = 50.0 +) -> list[tuple[Issue, float]]: """ Find issues within a specified radius of a target location. @@ -78,9 +78,7 @@ def find_nearby_issues( # Fast bounding box pre-filter with a 5% epsilon to optimize expensive haversine calculations epsilon_radius = radius_meters * 1.05 - min_lat, max_lat, min_lon, max_lon = get_bounding_box( - target_lat, target_lon, epsilon_radius - ) + min_lat, max_lat, min_lon, max_lon = get_bounding_box(target_lat, target_lon, epsilon_radius) for issue in issues: if issue.latitude is None or issue.longitude is None: @@ -89,15 +87,13 @@ def find_nearby_issues( # Skip issues outside the bounding box lon_delta = (issue.longitude - target_lon + 180) % 360 - 180 lon_half_width = max(target_lon - min_lon, max_lon - target_lon) - if not (min_lat <= issue.latitude <= max_lat and ( - abs(target_lat) >= 89.9 or abs(lon_delta) <= lon_half_width - )): + if not ( + min_lat <= issue.latitude <= max_lat + and (abs(target_lat) >= 89.9 or abs(lon_delta) <= lon_half_width) + ): continue - distance = haversine_distance( - target_lat, target_lon, - issue.latitude, issue.longitude - ) + distance = haversine_distance(target_lat, target_lon, issue.latitude, issue.longitude) if distance <= radius_meters: nearby_issues.append((issue, distance)) @@ -108,7 +104,7 @@ def find_nearby_issues( return nearby_issues -def cluster_issues_dbscan(issues: List[Issue], eps_meters: float = 30.0) -> List[List[Issue]]: +def cluster_issues_dbscan(issues: list[Issue], eps_meters: float = 30.0) -> list[list[Issue]]: """ Cluster issues using DBSCAN algorithm based on spatial proximity. @@ -122,17 +118,14 @@ def cluster_issues_dbscan(issues: List[Issue], eps_meters: float = 30.0) -> List """ # Filter issues with valid coordinates valid_issues = [ - issue for issue in issues - if issue.latitude is not None and issue.longitude is not None + issue for issue in issues if issue.latitude is not None and issue.longitude is not None ] if not valid_issues: return [] # Convert to numpy array for DBSCAN - coordinates = np.array([ - [issue.latitude, issue.longitude] for issue in valid_issues - ]) + coordinates = np.array([[issue.latitude, issue.longitude] for issue in valid_issues]) # Convert eps from meters to degrees (approximate) # 1 degree latitude ≈ 111,000 meters @@ -140,9 +133,7 @@ def cluster_issues_dbscan(issues: List[Issue], eps_meters: float = 30.0) -> List eps_degrees = eps_meters / 111000 # Rough approximation # Perform DBSCAN clustering - db = DBSCAN(eps=eps_degrees, min_samples=1, metric='haversine').fit( - np.radians(coordinates) - ) + db = DBSCAN(eps=eps_degrees, min_samples=1, metric="haversine").fit(np.radians(coordinates)) # Group issues by cluster clusters = {} @@ -155,7 +146,7 @@ def cluster_issues_dbscan(issues: List[Issue], eps_meters: float = 30.0) -> List return [cluster for label, cluster in clusters.items() if label != -1] -def get_cluster_representative(cluster: List[Issue]) -> Issue: +def get_cluster_representative(cluster: list[Issue]) -> Issue: """ Get the representative issue from a cluster. Uses the issue with the most upvotes, or the oldest if tie. @@ -170,15 +161,12 @@ def get_cluster_representative(cluster: List[Issue]) -> Issue: raise ValueError("Cluster cannot be empty") # Sort by upvotes (descending), then by creation date (ascending) - sorted_issues = sorted( - cluster, - key=lambda x: (-(x.upvotes or 0), x.created_at) - ) + sorted_issues = sorted(cluster, key=lambda x: (-(x.upvotes or 0), x.created_at)) return sorted_issues[0] -def calculate_cluster_centroid(cluster: List[Issue]) -> Tuple[float, float]: +def calculate_cluster_centroid(cluster: list[Issue]) -> tuple[float, float]: """ Calculate the centroid (average position) of a cluster of issues. @@ -189,8 +177,7 @@ def calculate_cluster_centroid(cluster: List[Issue]) -> Tuple[float, float]: Tuple of (latitude, longitude) representing the centroid """ valid_issues = [ - issue for issue in cluster - if issue.latitude is not None and issue.longitude is not None + issue for issue in cluster if issue.latitude is not None and issue.longitude is not None ] if not valid_issues: diff --git a/backend/test_ai_services.py b/backend/test_ai_services.py index 76881f24..f60f59c9 100644 --- a/backend/test_ai_services.py +++ b/backend/test_ai_services.py @@ -1,22 +1,23 @@ """ Test script to verify AI service dependency injection works correctly. """ + import asyncio -import pytest -import os import sys from pathlib import Path +import pytest + # Ensure repository root on sys.path so backend package resolves PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from backend.ai_interfaces import initialize_ai_services, get_ai_services +from backend.ai_interfaces import get_ai_services, initialize_ai_services from backend.mock_services import ( create_mock_action_plan_service, create_mock_chat_service, - create_mock_mla_summary_service + create_mock_mla_summary_service, ) @@ -54,9 +55,7 @@ async def test_ai_services(): print("✓ Chat service works") # Test MLA summary service - summary = await services.mla_summary_service.generate_mla_summary( - "Mumbai", "Dadar", "John Doe" - ) + summary = await services.mla_summary_service.generate_mla_summary("Mumbai", "Dadar", "John Doe") print(f"MLA summary: {summary[:50]}...") assert isinstance(summary, str) assert len(summary) > 0 diff --git a/backend/test_grievance_escalation.py b/backend/test_grievance_escalation.py index 66aad18d..4a155499 100644 --- a/backend/test_grievance_escalation.py +++ b/backend/test_grievance_escalation.py @@ -5,7 +5,7 @@ from backend.grievance_service import GrievanceService from backend.models import SeverityLevel -from datetime import datetime, timezone, timedelta + def test_escalation(): """Test the escalation engine functionality.""" @@ -20,7 +20,7 @@ def test_escalation(): "city": "Mumbai", "district": "Mumbai", "state": "Maharashtra", - "description": "Medical emergency response needed" + "description": "Medical emergency response needed", } grievance = service.create_grievance(grievance_data) @@ -43,9 +43,7 @@ def test_escalation(): # Test severity escalation print("Testing severity escalation...") success = service.escalate_grievance_severity( - grievance.id, - SeverityLevel.CRITICAL, - "Emergency situation escalated" + grievance.id, SeverityLevel.CRITICAL, "Emergency situation escalated" ) if success: @@ -76,7 +74,9 @@ def test_escalation(): print("Audit Trail:") audit_trail = service.get_grievance_audit_trail(grievance.id) for i, entry in enumerate(audit_trail, 1): - print(f"{i}. {entry['timestamp'][:19]}: {entry['previous_authority']} → {entry['new_authority']}") + print( + f"{i}. {entry['timestamp'][:19]}: {entry['previous_authority']} → {entry['new_authority']}" + ) print(f" Reason: {entry['reason']}, Notes: {entry.get('notes', 'N/A')}") print() @@ -86,9 +86,12 @@ def test_escalation(): # Note: In real scenario, this would be done by the periodic escalation check # For demo, we'll manually trigger escalation check stats = service.run_escalation_check() - print(f"Escalation check results: Evaluated {stats['evaluated']}, Escalated {stats['escalated']}") + print( + f"Escalation check results: Evaluated {stats['evaluated']}, Escalated {stats['escalated']}" + ) print("\n=== Test Complete ===") + if __name__ == "__main__": - test_escalation() \ No newline at end of file + test_escalation() diff --git a/backend/tests/test_detection_bytes.py b/backend/tests/test_detection_bytes.py index 15e71ddf..67464820 100644 --- a/backend/tests/test_detection_bytes.py +++ b/backend/tests/test_detection_bytes.py @@ -1,14 +1,13 @@ +import io +import os +import sys +import warnings +from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest -import warnings from fastapi.testclient import TestClient -from unittest.mock import MagicMock, AsyncMock, patch -import io -import json from PIL import Image -import httpx -import sys -import os # Suppress warnings for clean test output warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -21,17 +20,17 @@ sys.path.insert(0, str(PROJECT_ROOT)) # Set environment variable -os.environ['FRONTEND_URL'] = 'http://localhost:5173' +os.environ["FRONTEND_URL"] = "http://localhost:5173" # Mock magic module before any imports mock_magic = MagicMock() mock_magic.from_buffer.return_value = "image/jpeg" -sys.modules['magic'] = mock_magic +sys.modules["magic"] = mock_magic # Mock telegram mock_telegram = MagicMock() -sys.modules['telegram'] = mock_telegram -sys.modules['telegram.ext'] = mock_telegram.ext +sys.modules["telegram"] = mock_telegram +sys.modules["telegram.ext"] = mock_telegram.ext # Mock dependencies before importing app with patch("backend.main.create_all_ai_services") as mock_create_ai: @@ -42,6 +41,7 @@ from backend.main import app + @pytest.fixture def client(): # We want to mock httpx.AsyncClient but ensuring it returns a useful mock @@ -57,6 +57,7 @@ def client(): dummy_request = MagicMock() dummy_request.app.state.http_client = mock_client import backend.main as main_module + main_module.request = dummy_request # We need to ensure that when main.py does app.state.http_client = httpx.AsyncClient() @@ -64,9 +65,10 @@ def client(): # Let's rely on patching httpx.AsyncClient class constructor with patch("httpx.AsyncClient", return_value=mock_client): - with TestClient(app) as c: + with TestClient(app) as c: yield c + @pytest.mark.asyncio async def test_detect_vandalism_with_bytes(client): # We need to control the response for specific tests @@ -84,18 +86,22 @@ async def test_detect_vandalism_with_bytes(client): mock_client.post.return_value = mock_response # Create a dummy image bytes - img = Image.new('RGB', (100, 100), color='red') + img = Image.new("RGB", (100, 100), color="red") img_byte_arr = io.BytesIO() - img.save(img_byte_arr, format='JPEG') + img.save(img_byte_arr, format="JPEG") img_bytes = img_byte_arr.getvalue() # Send request - with patch('backend.main.validate_uploaded_file'), \ - patch('backend.main.validate_image_for_processing'), \ - patch('backend.main.detect_vandalism_unified', AsyncMock(return_value=[{"label": "graffiti", "score": 0.95}])): + with ( + patch("backend.main.validate_uploaded_file"), + patch("backend.main.validate_image_for_processing"), + patch( + "backend.main.detect_vandalism_unified", + AsyncMock(return_value=[{"label": "graffiti", "score": 0.95}]), + ), + ): response = client.post( - "/api/detect-vandalism", - files={"image": ("test.jpg", img_bytes, "image/jpeg")} + "/api/detect-vandalism", files={"image": ("test.jpg", img_bytes, "image/jpeg")} ) assert response.status_code == 200 @@ -106,6 +112,7 @@ async def test_detect_vandalism_with_bytes(client): # Client not invoked because detection is mocked above + @pytest.mark.asyncio async def test_detect_infrastructure_with_bytes(client): mock_client = app.state.http_client @@ -121,19 +128,24 @@ async def test_detect_infrastructure_with_bytes(client): dummy_request = MagicMock() dummy_request.app.state.http_client = mock_client import backend.main as main_module + main_module.request = dummy_request - img = Image.new('RGB', (100, 100), color='blue') + img = Image.new("RGB", (100, 100), color="blue") img_byte_arr = io.BytesIO() - img.save(img_byte_arr, format='JPEG') + img.save(img_byte_arr, format="JPEG") img_bytes = img_byte_arr.getvalue() - with patch('backend.main.validate_uploaded_file'), \ - patch('backend.main.validate_image_for_processing'), \ - patch('backend.main.detect_infrastructure_unified', AsyncMock(return_value=[{"label": "fallen tree", "score": 0.8}])): + with ( + patch("backend.main.validate_uploaded_file"), + patch("backend.main.validate_image_for_processing"), + patch( + "backend.main.detect_infrastructure_unified", + AsyncMock(return_value=[{"label": "fallen tree", "score": 0.8}]), + ), + ): response = client.post( - "/api/detect-infrastructure", - files={"image": ("test.jpg", img_bytes, "image/jpeg")} + "/api/detect-infrastructure", files={"image": ("test.jpg", img_bytes, "image/jpeg")} ) assert response.status_code == 200 diff --git a/backend/tests/test_new_features.py b/backend/tests/test_new_features.py index 6806f3d7..7e9ecc1b 100644 --- a/backend/tests/test_new_features.py +++ b/backend/tests/test_new_features.py @@ -1,59 +1,64 @@ -import pytest import io import os import sys -from unittest.mock import MagicMock, AsyncMock, patch from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from fastapi.testclient import TestClient # Setup environment PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -os.environ['FRONTEND_URL'] = 'http://localhost:5173' +os.environ["FRONTEND_URL"] = "http://localhost:5173" # Mock magic mock_magic = MagicMock() mock_magic.from_buffer.return_value = "image/jpeg" -sys.modules['magic'] = mock_magic +sys.modules["magic"] = mock_magic # Mock telegram mock_telegram = MagicMock() -sys.modules['telegram'] = mock_telegram -sys.modules['telegram.ext'] = mock_telegram.ext +sys.modules["telegram"] = mock_telegram +sys.modules["telegram.ext"] = mock_telegram.ext # Import main (will trigger app creation, but lifespan won't run yet) import backend.main from backend.main import app + @pytest.fixture def client_with_mock_http(): # Patch create_all_ai_services where it is used (in backend.main) with patch.object(backend.main, "create_all_ai_services") as mock_create: - mock_create.return_value = (AsyncMock(), AsyncMock(), AsyncMock()) - - # Mock http client - mock_http = AsyncMock() - # Ensure app state has http_client before dependencies try to use it? - # The lifespan sets it up. TestClient runs lifespan. - # But we want to override it. - # If we set it here, lifespan might overwrite it. - - # Option: Patch httpx.AsyncClient to return our mock - mock_http.__aenter__.return_value = mock_http - with patch("httpx.AsyncClient", return_value=mock_http): - with TestClient(app) as c: - # After startup, app.state.http_client should be set. - # Since we patched AsyncClient, it should be our mock_http. - yield c, mock_http + mock_create.return_value = (AsyncMock(), AsyncMock(), AsyncMock()) + + # Mock http client + mock_http = AsyncMock() + # Ensure app state has http_client before dependencies try to use it? + # The lifespan sets it up. TestClient runs lifespan. + # But we want to override it. + # If we set it here, lifespan might overwrite it. + + # Option: Patch httpx.AsyncClient to return our mock + mock_http.__aenter__.return_value = mock_http + with patch("httpx.AsyncClient", return_value=mock_http): + with TestClient(app) as c: + # After startup, app.state.http_client should be set. + # Since we patched AsyncClient, it should be our mock_http. + yield c, mock_http + def create_test_image(): from PIL import Image - img = Image.new('RGB', (100, 100), color='white') + + img = Image.new("RGB", (100, 100), color="white") img_byte_arr = io.BytesIO() - img.save(img_byte_arr, format='JPEG') + img.save(img_byte_arr, format="JPEG") return img_byte_arr.getvalue() + def test_detect_waste(client_with_mock_http): client, mock_http = client_with_mock_http @@ -68,10 +73,9 @@ def test_detect_waste(client_with_mock_http): img_bytes = create_test_image() - with patch('backend.main.validate_uploaded_file'): + with patch("backend.main.validate_uploaded_file"): response = client.post( - "/api/detect-waste", - files={"image": ("test.jpg", img_bytes, "image/jpeg")} + "/api/detect-waste", files={"image": ("test.jpg", img_bytes, "image/jpeg")} ) assert response.status_code == 200 @@ -79,6 +83,7 @@ def test_detect_waste(client_with_mock_http): assert data["waste_type"] == "plastic bottle" assert data["confidence"] == 0.95 + def test_detect_civic_eye(client_with_mock_http): client, mock_http = client_with_mock_http mock_http.post.reset_mock() @@ -90,16 +95,15 @@ def test_detect_civic_eye(client_with_mock_http): mock_response.json.return_value = [ {"label": "safe area", "score": 0.9}, {"label": "clean street", "score": 0.85}, - {"label": "good infrastructure", "score": 0.8} + {"label": "good infrastructure", "score": 0.8}, ] mock_http.post.return_value = mock_response img_bytes = create_test_image() - with patch('backend.main.validate_uploaded_file'): + with patch("backend.main.validate_uploaded_file"): response = client.post( - "/api/detect-civic-eye", - files={"image": ("test.jpg", img_bytes, "image/jpeg")} + "/api/detect-civic-eye", files={"image": ("test.jpg", img_bytes, "image/jpeg")} ) assert response.status_code == 200 @@ -108,6 +112,7 @@ def test_detect_civic_eye(client_with_mock_http): assert data["cleanliness"]["status"] == "clean street" assert data["infrastructure"]["status"] == "good infrastructure" + def test_transcribe_audio(client_with_mock_http): client, mock_http = client_with_mock_http mock_http.post.reset_mock() @@ -121,8 +126,7 @@ def test_transcribe_audio(client_with_mock_http): audio_content = b"fake audio content" response = client.post( - "/api/transcribe-audio", - files={"file": ("test.wav", audio_content, "audio/wav")} + "/api/transcribe-audio", files={"file": ("test.wav", audio_content, "audio/wav")} ) assert response.status_code == 200 diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 9f44c1db..2426a7a6 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -1,20 +1,38 @@ -import pytest import warnings -from pydantic import ValidationError from datetime import datetime +import pytest +from pydantic import ValidationError + # Suppress warnings for clean test output warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=FutureWarning) from backend.schemas import ( - IssueCategory, IssueStatus, ActionPlan, ChatRequest, ChatResponse, - IssueResponse, IssueCreateRequest, IssueCreateResponse, VoteRequest, - VoteResponse, IssueStatusUpdateRequest, IssueStatusUpdateResponse, - PushSubscriptionRequest, PushSubscriptionResponse, DetectionResponse, - UrgencyAnalysisRequest, UrgencyAnalysisResponse, HealthResponse, - MLStatusResponse, ResponsibilityMapResponse, ErrorResponse, SuccessResponse + ActionPlan, + ChatRequest, + ChatResponse, + DetectionResponse, + ErrorResponse, + HealthResponse, + IssueCategory, + IssueCreateRequest, + IssueCreateResponse, + IssueResponse, + IssueStatus, + IssueStatusUpdateRequest, + IssueStatusUpdateResponse, + MLStatusResponse, + PushSubscriptionRequest, + PushSubscriptionResponse, + ResponsibilityMapResponse, + SuccessResponse, + UrgencyAnalysisRequest, + UrgencyAnalysisResponse, + VoteRequest, + VoteResponse, ) + def test_issue_category_enum(): assert IssueCategory.ROAD == "Road" assert IssueCategory.WATER == "Water" @@ -23,6 +41,7 @@ def test_issue_category_enum(): assert IssueCategory.COLLEGE_INFRA == "College Infra" assert IssueCategory.WOMEN_SAFETY == "Women Safety" + def test_issue_status_enum(): assert IssueStatus.OPEN == "open" assert IssueStatus.VERIFIED == "verified" @@ -30,13 +49,17 @@ def test_issue_status_enum(): assert IssueStatus.IN_PROGRESS == "in_progress" assert IssueStatus.RESOLVED == "resolved" + def test_action_plan(): - plan = ActionPlan(whatsapp="Test message", email_subject="Subject", email_body="Body", x_post="Post") + plan = ActionPlan( + whatsapp="Test message", email_subject="Subject", email_body="Body", x_post="Post" + ) assert plan.whatsapp == "Test message" assert plan.email_subject == "Subject" assert plan.email_body == "Body" assert plan.x_post == "Post" + def test_chat_request(): request = ChatRequest(query="Hello") assert request.query == "Hello" @@ -47,22 +70,28 @@ def test_chat_request(): with pytest.raises(ValidationError): ChatRequest(query=" ") + def test_chat_response(): response = ChatResponse(response="Hi there") assert response.response == "Hi there" + def test_issue_response(): issue = IssueResponse( - id=1, category="Road", description="Pothole", created_at=datetime.now(), - status="open", upvotes=0 + id=1, + category="Road", + description="Pothole", + created_at=datetime.now(), + status="open", + upvotes=0, ) assert issue.id == 1 assert issue.category == "Road" + def test_issue_create_request(): request = IssueCreateRequest( - description="Test issue", category=IssueCategory.ROAD, - latitude=12.34, longitude=56.78 + description="Test issue", category=IssueCategory.ROAD, latitude=12.34, longitude=56.78 ) assert request.description == "Test issue" assert request.category == IssueCategory.ROAD @@ -70,11 +99,13 @@ def test_issue_create_request(): with pytest.raises(ValidationError): IssueCreateRequest(description="", category=IssueCategory.ROAD) + def test_issue_create_response(): response = IssueCreateResponse(id=1, message="Created") assert response.id == 1 assert response.message == "Created" + def test_vote_request(): request = VoteRequest(vote_type="up") assert request.vote_type == "up" @@ -82,18 +113,19 @@ def test_vote_request(): with pytest.raises(ValidationError): VoteRequest(vote_type="invalid") + def test_vote_response(): response = VoteResponse(id=1, upvotes=5, message="Voted") assert response.id == 1 assert response.upvotes == 5 + def test_issue_status_update_request(): - request = IssueStatusUpdateRequest( - reference_id="ref123", status=IssueStatus.RESOLVED - ) + request = IssueStatusUpdateRequest(reference_id="ref123", status=IssueStatus.RESOLVED) assert request.reference_id == "ref123" assert request.status == IssueStatus.RESOLVED + def test_issue_status_update_response(): response = IssueStatusUpdateResponse( id=1, reference_id="ref123", status=IssueStatus.RESOLVED, message="Updated" @@ -101,49 +133,55 @@ def test_issue_status_update_response(): assert response.id == 1 assert response.status == IssueStatus.RESOLVED + def test_push_subscription_request(): - request = PushSubscriptionRequest( - endpoint="https://example.com", p256dh="key", auth="secret" - ) + request = PushSubscriptionRequest(endpoint="https://example.com", p256dh="key", auth="secret") assert request.endpoint == "https://example.com" + def test_push_subscription_response(): response = PushSubscriptionResponse(id=1, message="Subscribed") assert response.id == 1 + def test_detection_response(): response = DetectionResponse(detections=[{"object": "car", "confidence": 0.9}]) assert len(response.detections) == 1 assert response.detections[0]["object"] == "car" + def test_urgency_analysis_request(): - request = UrgencyAnalysisRequest( - description="Urgent issue", category=IssueCategory.ROAD - ) + request = UrgencyAnalysisRequest(description="Urgent issue", category=IssueCategory.ROAD) assert request.description == "Urgent issue" + def test_urgency_analysis_response(): response = UrgencyAnalysisResponse( urgency_level="high", reasoning="Critical", recommended_actions=["Act now"] ) assert response.urgency_level == "high" + def test_health_response(): response = HealthResponse(status="healthy", timestamp=datetime.now()) assert response.status == "healthy" + def test_ml_status_response(): response = MLStatusResponse(status="loaded", models_loaded=["model1"]) assert response.status == "loaded" + def test_responsibility_map_response(): response = ResponsibilityMapResponse(data={"key": "value"}) assert response.data["key"] == "value" + def test_error_response(): response = ErrorResponse(error="Error", error_code="E001") assert response.error == "Error" + def test_success_response(): response = SuccessResponse(message="Success") assert response.message == "Success" diff --git a/backend/tests/test_severity.py b/backend/tests/test_severity.py index efad88a4..afbaa8ca 100644 --- a/backend/tests/test_severity.py +++ b/backend/tests/test_severity.py @@ -1,10 +1,11 @@ -import pytest -import warnings -from fastapi.testclient import TestClient -from unittest.mock import AsyncMock, patch, MagicMock -import sys import os +import sys +import warnings from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient # Suppress warnings for clean test output warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -15,33 +16,34 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -# Also ensure backend directory is on path for direct module imports -BACKEND_DIR = Path(__file__).resolve().parents[1] -if str(BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(BACKEND_DIR)) +# backend/ deliberately stays off sys.path: having it there lets `models` and +# `backend.models` load as two separate modules, which double-registers every +# SQLAlchemy table. See conftest.py. # Set environment variable -os.environ['FRONTEND_URL'] = 'http://localhost:5173' +os.environ["FRONTEND_URL"] = "http://localhost:5173" # Mock magic module mock_magic = MagicMock() mock_magic.from_buffer.return_value = "image/jpeg" -sys.modules['magic'] = mock_magic +sys.modules["magic"] = mock_magic # Mock telegram mock_telegram = MagicMock() -sys.modules['telegram'] = mock_telegram -sys.modules['telegram.ext'] = mock_telegram.ext +sys.modules["telegram"] = mock_telegram +sys.modules["telegram.ext"] = mock_telegram.ext from backend.main import app + @pytest.mark.asyncio async def test_detect_severity_endpoint(): # Mock AI services initialization to prevent startup failure - with patch('backend.main.create_all_ai_services') as mock_create_services, \ - patch('backend.main.initialize_ai_services') as mock_init_services, \ - patch('backend.main.detect_severity_clip', new_callable=AsyncMock) as mock_detect: - + with ( + patch("backend.main.create_all_ai_services") as mock_create_services, + patch("backend.main.initialize_ai_services"), + patch("backend.main.detect_severity_clip", new_callable=AsyncMock) as mock_detect, + ): # Setup mocks mock_create_services.return_value = (MagicMock(), MagicMock(), MagicMock()) @@ -49,7 +51,7 @@ async def test_detect_severity_endpoint(): mock_detect.return_value = { "level": "Critical", "raw_label": "critical emergency", - "confidence": 0.95 + "confidence": 0.95, } # Create a dummy image file diff --git a/backend/unified_detection_service.py b/backend/unified_detection_service.py index 4fc9d58f..c8e3abae 100644 --- a/backend/unified_detection_service.py +++ b/backend/unified_detection_service.py @@ -8,13 +8,13 @@ Issue #76: Create a Local Machine Learning model """ -import os import logging -from typing import List, Dict, Optional -from PIL import Image +import os from enum import Enum -from backend.exceptions import DetectionException, ServiceUnavailableException +from PIL import Image + +from backend.exceptions import ServiceUnavailableException # Configure logging logger = logging.getLogger(__name__) @@ -26,6 +26,7 @@ class DetectionBackend(Enum): """Available detection backends.""" + LOCAL = "local" HUGGINGFACE = "huggingface" AUTO = "auto" # Try local first, fallback to HF @@ -34,28 +35,29 @@ class DetectionBackend(Enum): class UnifiedDetectionService: """ Unified service for civic issue detection. - + This service provides: - Automatic backend selection (local or HF API) - Graceful fallback when local model fails - Consistent interface for all detection types - Performance monitoring and logging """ - + def __init__(self, backend: DetectionBackend = DetectionBackend.AUTO): self.backend = backend self._local_available = None self._hf_available = None - + async def _check_local_available(self) -> bool: """Check if local ML service is available.""" if self._local_available is not None: return self._local_available - + try: - from local_ml_service import get_general_model + from backend.local_ml_service import get_general_model + model = get_general_model() - + # Check if model is loaded if model is None: self._local_available = False @@ -64,39 +66,47 @@ async def _check_local_available(self) -> bool: # Try a simple prediction to verify # Run in threadpool as it might be blocking from fastapi.concurrency import run_in_threadpool + test_image = Image.new("RGB", (224, 224), color="white") await run_in_threadpool(model.predict, test_image, verbose=False) - + self._local_available = True return True - + except Exception as e: logger.warning(f"Local ML service unavailable: {e}") self._local_available = False return False - + async def _check_hf_available(self) -> bool: """Check if Hugging Face API is available.""" if self._hf_available is not None: return self._hf_available - + try: - # HF token present indicates API might be available - token = os.environ.get("HF_TOKEN") - self._hf_available = True # Assume available, actual call will verify + # A token is required to call the hosted inference API. This used to + # read the variable and then set _hf_available = True regardless, so + # an unconfigured deployment reported the hosted backend as + # available and never fell back to the local model -- every request + # failed at call time instead of routing around the gap. + token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") + self._hf_available = bool(token) + if not self._hf_available: + logger.warning("HF_TOKEN is not set; the hosted inference backend is unavailable.") return self._hf_available except Exception: + logger.exception("Failed to determine Hugging Face availability") self._hf_available = False return False - + async def _get_detection_backend(self) -> str: """Determine which backend to use based on configuration and availability.""" if self.backend == DetectionBackend.LOCAL: return "local" if await self._check_local_available() else None - + elif self.backend == DetectionBackend.HUGGINGFACE: return "huggingface" if await self._check_hf_available() else None - + else: # AUTO if USE_LOCAL_MODEL and await self._check_local_available(): return "local" @@ -105,92 +115,104 @@ async def _get_detection_backend(self) -> str: return "huggingface" else: return None - - async def detect_vandalism(self, image: Image.Image) -> List[Dict]: + + async def detect_vandalism(self, image: Image.Image) -> list[dict]: """ Detect vandalism in an image. - + Args: image: PIL Image to analyze - + Returns: List of detections with 'label', 'confidence', and 'box' keys - + Raises: ServiceUnavailableException: If no detection backend is available DetectionException: If detection fails """ backend = await self._get_detection_backend() - + if backend == "local": - from local_ml_service import detect_vandalism_local + from backend.local_ml_service import detect_vandalism_local + return await detect_vandalism_local(image) - + elif backend == "huggingface": - from hf_service import detect_vandalism_clip + from backend.hf_service import detect_vandalism_clip + return await detect_vandalism_clip(image) - + else: logger.error("No detection backend available") - raise ServiceUnavailableException("Detection service", details={"detection_type": "vandalism"}) - - async def detect_infrastructure(self, image: Image.Image) -> List[Dict]: + raise ServiceUnavailableException( + "Detection service", details={"detection_type": "vandalism"} + ) + + async def detect_infrastructure(self, image: Image.Image) -> list[dict]: """ Detect infrastructure damage in an image. - + Args: image: PIL Image to analyze - + Returns: List of detections with 'label', 'confidence', and 'box' keys - + Raises: ServiceUnavailableException: If no detection backend is available DetectionException: If detection fails """ backend = await self._get_detection_backend() - + if backend == "local": - from local_ml_service import detect_infrastructure_local + from backend.local_ml_service import detect_infrastructure_local + return await detect_infrastructure_local(image) - + elif backend == "huggingface": - from hf_service import detect_infrastructure_clip + from backend.hf_service import detect_infrastructure_clip + return await detect_infrastructure_clip(image) - + else: logger.error("No detection backend available") - raise ServiceUnavailableException("Detection service", details={"detection_type": "infrastructure"}) - - async def detect_flooding(self, image: Image.Image) -> List[Dict]: + raise ServiceUnavailableException( + "Detection service", details={"detection_type": "infrastructure"} + ) + + async def detect_flooding(self, image: Image.Image) -> list[dict]: """ Detect flooding/waterlogging in an image. - + Args: image: PIL Image to analyze - + Returns: List of detections with 'label', 'confidence', and 'box' keys - + Raises: ServiceUnavailableException: If no detection backend is available DetectionException: If detection fails """ backend = await self._get_detection_backend() - + if backend == "local": - from local_ml_service import detect_flooding_local + from backend.local_ml_service import detect_flooding_local + return await detect_flooding_local(image) - + elif backend == "huggingface": - from hf_service import detect_flooding_clip + from backend.hf_service import detect_flooding_clip + return await detect_flooding_clip(image) - + else: logger.error("No detection backend available") - raise ServiceUnavailableException("Detection service", details={"detection_type": "flooding"}) + raise ServiceUnavailableException( + "Detection service", details={"detection_type": "flooding"} + ) - async def detect_garbage(self, image: Image.Image) -> List[Dict]: + async def detect_garbage(self, image: Image.Image) -> list[dict]: """ Detect garbage/waste in an image. @@ -204,37 +226,44 @@ async def detect_garbage(self, image: Image.Image) -> List[Dict]: backend = await self._get_detection_backend() if backend == "local": - from backend.garbage_detection import detect_garbage # Local model expects image source, but PIL image works if model supports it # The existing detect_garbage uses model.predict(image_source) # Ultralytics YOLO supports PIL Image directly from fastapi.concurrency import run_in_threadpool + + from backend.garbage_detection import detect_garbage + return await run_in_threadpool(detect_garbage, image) elif backend == "huggingface": from backend.hf_api_service import detect_waste_clip + result = await detect_waste_clip(image) # Map classification to detection format if result and result.get("waste_type") != "unknown": - return [{ - "label": result["waste_type"], - "confidence": result.get("confidence", 0.0), - "box": [] # No bounding box for classification - }] + return [ + { + "label": result["waste_type"], + "confidence": result.get("confidence", 0.0), + "box": [], # No bounding box for classification + } + ] return [] else: logger.error("No detection backend available") - raise ServiceUnavailableException("Detection service", details={"detection_type": "garbage"}) - - async def detect_all(self, image: Image.Image) -> Dict[str, List[Dict]]: + raise ServiceUnavailableException( + "Detection service", details={"detection_type": "garbage"} + ) + + async def detect_all(self, image: Image.Image) -> dict[str, list[dict]]: """ Run all detection types on an image. - + Args: image: PIL Image to analyze - + Returns: Dictionary mapping detection type to list of results """ @@ -242,41 +271,42 @@ async def detect_all(self, image: Image.Image) -> Dict[str, List[Dict]]: "vandalism": await self.detect_vandalism(image), "infrastructure": await self.detect_infrastructure(image), "flooding": await self.detect_flooding(image), - "garbage": await self.detect_garbage(image) + "garbage": await self.detect_garbage(image), } - - async def get_status(self) -> Dict: + + async def get_status(self) -> dict: """ Get the current status of the detection service. - + Returns: Dictionary with service status information """ local_available = await self._check_local_available() hf_available = await self._check_hf_available() - + status = { "use_local_model": USE_LOCAL_MODEL, "enable_hf_fallback": ENABLE_HF_FALLBACK, "local_backend": { "available": local_available, - "status": "ready" if local_available else "unavailable" + "status": "ready" if local_available else "unavailable", }, "huggingface_backend": { "available": hf_available, - "status": "ready" if hf_available else "unavailable" + "status": "ready" if hf_available else "unavailable", }, - "active_backend": await self._get_detection_backend() + "active_backend": await self._get_detection_backend(), } - + # Add local model details if available if local_available: try: - from local_ml_service import get_detection_status + from backend.local_ml_service import get_detection_status + status["local_backend"]["details"] = await get_detection_status() except Exception: - pass - + logger.debug("Local backend detail lookup failed", exc_info=True) + return status @@ -293,31 +323,31 @@ def get_detection_service() -> UnifiedDetectionService: # Convenience functions that use the default service -async def detect_vandalism(image: Image.Image) -> List[Dict]: +async def detect_vandalism(image: Image.Image) -> list[dict]: """Detect vandalism using the default service.""" return await get_detection_service().detect_vandalism(image) -async def detect_infrastructure(image: Image.Image) -> List[Dict]: +async def detect_infrastructure(image: Image.Image) -> list[dict]: """Detect infrastructure damage using the default service.""" return await get_detection_service().detect_infrastructure(image) -async def detect_flooding(image: Image.Image) -> List[Dict]: +async def detect_flooding(image: Image.Image) -> list[dict]: """Detect flooding using the default service.""" return await get_detection_service().detect_flooding(image) -async def detect_garbage(image: Image.Image) -> List[Dict]: +async def detect_garbage(image: Image.Image) -> list[dict]: """Detect garbage using the default service.""" return await get_detection_service().detect_garbage(image) -async def detect_all(image: Image.Image) -> Dict[str, List[Dict]]: +async def detect_all(image: Image.Image) -> dict[str, list[dict]]: """Run all detections using the default service.""" return await get_detection_service().detect_all(image) -async def get_detection_status() -> Dict: +async def get_detection_status() -> dict: """Get detection service status.""" return await get_detection_service().get_status() diff --git a/backend/vandalism_detection.py b/backend/vandalism_detection.py index 1f04472e..2267c995 100644 --- a/backend/vandalism_detection.py +++ b/backend/vandalism_detection.py @@ -1,6 +1,8 @@ -from backend.local_ml_service import detect_vandalism_local from PIL import Image +from backend.local_ml_service import detect_vandalism_local + + async def detect_vandalism(image: Image.Image): """ Wrapper for vandalism detection using Local ML Service. diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..748eea86 --- /dev/null +++ b/conftest.py @@ -0,0 +1,24 @@ +"""Pytest bootstrap. + +Guarantees the repository root is importable so `backend.*` resolves from any +test, and asserts that backend/ never lands on sys.path. Putting backend/ on the +path lets `models` and `backend.models` both import as separate modules, which +registers every SQLAlchemy table twice and made `backend.main` fail at import. +Individual test files used to do this themselves with sys.path hacks. +""" + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent +BACKEND_DIR = REPO_ROOT / "backend" + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def pytest_configure(config): + """Drop backend/ from sys.path if any module put it there on import.""" + backend = str(BACKEND_DIR) + while backend in sys.path: + sys.path.remove(backend) diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /dev/null +++ b/frontend/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/frontend/android/app/.gitignore b/frontend/android/app/.gitignore new file mode 100644 index 00000000..043df802 --- /dev/null +++ b/frontend/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle new file mode 100644 index 00000000..c3b3b5e7 --- /dev/null +++ b/frontend/android/app/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'com.android.application' + +// Release signing is driven entirely by environment variables so that no +// keystore or password is ever committed. CI supplies them from repository +// secrets; a local release build without them falls back to the debug signing +// config, which produces an installable APK that the Play Store will reject -- +// deliberately, so an unsigned artifact cannot be mistaken for a shippable one. +// These are deliberately NOT named keystorePath/keyAlias/keyPassword: inside the +// signingConfigs block those are DSL setter names, so `keyAlias keyAlias` parses +// as invoking the String as a method and the build dies with +// "No signature of method: java.lang.String.call()". +def releaseKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH") +def releaseStorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") +def releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS") +def releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") +def hasReleaseSigning = releaseKeystorePath != null && !releaseKeystorePath.isEmpty() && file(releaseKeystorePath).exists() + +// versionCode must increase on every upload. CI passes the run number. +def buildVersionCode = (System.getenv("ANDROID_VERSION_CODE") ?: "1") as Integer +def buildVersionName = System.getenv("ANDROID_VERSION_NAME") ?: "1.0.0" + +android { + namespace = "com.vishwaguru.app" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.vishwaguru.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode buildVersionCode + versionName buildVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + signingConfigs { + release { + if (hasReleaseSigning) { + storeFile file(releaseKeystorePath) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug + } + } + + packagingOptions { + resources { + excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE*', 'META-INF/NOTICE*'] + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/frontend/android/app/capacitor.build.gradle b/frontend/android/app/capacitor.build.gradle new file mode 100644 index 00000000..5b7dfcf3 --- /dev/null +++ b/frontend/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-camera') + implementation project(':capacitor-geolocation') + implementation project(':capacitor-network') + implementation project(':capacitor-splash-screen') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/frontend/android/app/proguard-rules.pro b/frontend/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/frontend/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 00000000..f2c2217e --- /dev/null +++ b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/frontend/android/app/src/debug/AndroidManifest.xml b/frontend/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..d8b6e67e --- /dev/null +++ b/frontend/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/frontend/android/app/src/debug/res/xml/network_security_config.xml b/frontend/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..6eab6e3e --- /dev/null +++ b/frontend/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,24 @@ + + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + 192.168.0.0 + 192.168.66.197 + + diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0529b114 --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/java/com/vishwaguru/app/MainActivity.java b/frontend/android/app/src/main/java/com/vishwaguru/app/MainActivity.java new file mode 100644 index 00000000..26642569 --- /dev/null +++ b/frontend/android/app/src/main/java/com/vishwaguru/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.vishwaguru.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 00000000..e31573b4 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 00000000..f7a64923 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 00000000..80772550 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 00000000..14c6c8fe Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 00000000..244ca250 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 00000000..74faaa58 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 00000000..e944f4ad Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 00000000..564a82ff Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 00000000..bfabe687 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 00000000..69290712 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/splash.png b/frontend/android/app/src/main/res/drawable/splash.png new file mode 100644 index 00000000..f7a64923 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable/splash.png differ diff --git a/frontend/android/app/src/main/res/layout/activity_main.xml b/frontend/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b5ad1387 --- /dev/null +++ b/frontend/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..c023e505 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2127973b Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..b441f37d Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..72905b85 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..8ed0605c Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..9502e47a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..4d1e0771 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..df0f1588 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..853db043 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..6cdf97c1 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2960cbb6 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..8e3093a8 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..46de6e25 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d2ea9abe Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..a40d73e9 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..c5d5899f --- /dev/null +++ b/frontend/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..a80cd5d5 --- /dev/null +++ b/frontend/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + VishwaGuru + VishwaGuru + com.vishwaguru.app + com.vishwaguru.app + diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..be874e54 --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/xml/file_paths.xml b/frontend/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bd0c4d80 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 00000000..02973278 --- /dev/null +++ b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle new file mode 100644 index 00000000..f8f0e43b --- /dev/null +++ b/frontend/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/frontend/android/capacitor.settings.gradle b/frontend/android/capacitor.settings.gradle new file mode 100644 index 00000000..2f742b24 --- /dev/null +++ b/frontend/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-camera' +project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android') + +include ':capacitor-geolocation' +project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android') + +include ':capacitor-network' +project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 00000000..2e87c52f --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/frontend/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1c60ff8c --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,11 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +# 10 minutes, not the scaffolded 10 seconds. The wrapper applies this as a +# socket READ timeout, so on a slow or bursty link it aborts a partially +# downloaded distribution rather than waiting -- the ~230MB download here +# failed at 50% with SocketTimeoutException and started over each retry. +networkTimeout=600000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/android/gradlew b/frontend/android/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/frontend/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/frontend/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frontend/android/settings.gradle b/frontend/android/settings.gradle new file mode 100644 index 00000000..3b4431d7 --- /dev/null +++ b/frontend/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/frontend/android/variables.gradle b/frontend/android/variables.gradle new file mode 100644 index 00000000..ee4ba41c --- /dev/null +++ b/frontend/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/frontend/assets/icon-background.png b/frontend/assets/icon-background.png new file mode 100644 index 00000000..d2befccb Binary files /dev/null and b/frontend/assets/icon-background.png differ diff --git a/frontend/assets/icon-foreground.png b/frontend/assets/icon-foreground.png new file mode 100644 index 00000000..486a9c5d Binary files /dev/null and b/frontend/assets/icon-foreground.png differ diff --git a/frontend/assets/icon.png b/frontend/assets/icon.png new file mode 100644 index 00000000..430e4604 Binary files /dev/null and b/frontend/assets/icon.png differ diff --git a/frontend/assets/splash-dark.png b/frontend/assets/splash-dark.png new file mode 100644 index 00000000..c26c99dc Binary files /dev/null and b/frontend/assets/splash-dark.png differ diff --git a/frontend/assets/splash.png b/frontend/assets/splash.png new file mode 100644 index 00000000..c26c99dc Binary files /dev/null and b/frontend/assets/splash.png differ diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts new file mode 100644 index 00000000..1a120dc2 --- /dev/null +++ b/frontend/capacitor.config.ts @@ -0,0 +1,49 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +/** + * Capacitor configuration for the Android build. + * + * `androidScheme: 'https'` keeps the WebView origin at https://localhost. That + * origin is what the backend's CORS allowlist admits (see MOBILE_APP_ORIGINS in + * backend/main.py), and it avoids Android's cleartext-traffic block, which + * rejects http:// requests by default from API 28 onward. + * + * The app talks to the API through VITE_API_URL, baked in at build time. There + * is no dev proxy or Netlify redirect inside the WebView, so that value must be + * an absolute https:// URL for any device build. + */ +const config: CapacitorConfig = { + appId: 'com.vishwaguru.app', + appName: 'VishwaGuru', + webDir: 'dist', + android: { + // The WebView serves the app from https://localhost, so an http:// API is + // blocked as mixed content -- a Chromium rule, separate from Android's + // cleartext-traffic policy, and not something the network security config + // can waive. + // + // Production is unaffected: the API is served over HTTPS there, so this + // stays false in anything shipped. It exists only so a debug build can talk + // to an http:// dev server on the LAN, and must be opted into explicitly: + // + // CAP_ALLOW_MIXED_CONTENT=true npm run mobile:sync + allowMixedContent: process.env.CAP_ALLOW_MIXED_CONTENT === 'true', + }, + server: { + androidScheme: 'https', + }, + plugins: { + SplashScreen: { + launchShowDuration: 1200, + backgroundColor: '#0D1117', + androidScaleType: 'CENTER_CROP', + showSpinner: false, + }, + StatusBar: { + style: 'DARK', + backgroundColor: '#0D1117', + }, + }, +}; + +export default config; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4fa125da..60279ed3 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,7 +5,20 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + // `android/` holds the native project. Capacitor copies the built web + // bundle into android/app/src/main/assets/public on every sync, so without + // this eslint reports hundreds of errors against minified output. + globalIgnores([ + 'dist', + 'dev-dist', + 'coverage', + 'node_modules', + 'android', + 'ios', + 'public/sw.js', + ]), + + // Application source: browser environment. { files: ['**/*.{js,jsx}'], extends: [ @@ -14,7 +27,7 @@ export default defineConfig([ reactRefresh.configs.vite, ], languageOptions: { - ecmaVersion: 2020, + ecmaVersion: 2022, globals: globals.browser, parserOptions: { ecmaVersion: 'latest', @@ -23,7 +36,62 @@ export default defineConfig([ }, }, rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^[A-Z_]', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + + // eslint-plugin-react-hooks v7 ships the React Compiler ruleset. The four + // rules below are compiler-readiness checks, not correctness failures: + // they fire on the ref-access and fetch-in-effect patterns used + // throughout the twenty detector components. Clearing them means + // restructuring those components, which is the consolidation work + // deliberately deferred until after the first mobile release, so they are + // warnings rather than errors and remain visible in every lint run. + // + // react-hooks/rules-of-hooks stays an error on purpose. It caught a real + // crash: views/ActionView.jsx called useEffect after an early return, so + // the hook count changed with props. + 'react-hooks/immutability': 'warn', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/static-components': 'warn', + 'react-hooks/rules-of-hooks': 'error', + }, + }, + + // Tests, mocks and setup run under Jest/jsdom with node globals available. + // Without this block eslint reported ~300 no-undef errors for `describe`, + // `it`, `expect`, `jest`, `global` and `process`. + { + files: [ + '**/__tests__/**/*.{js,jsx}', + '**/__mocks__/**/*.{js,jsx}', + '**/*.{test,spec}.{js,jsx}', + 'src/setupTests.js', + ], + languageOptions: { + globals: { + ...globals.browser, + ...globals.jest, + ...globals.node, + }, + }, + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + + // Build/tooling config files run in Node. + { + files: ['*.config.js', 'jest.transform.js', 'babel.config.js'], + languageOptions: { + globals: { ...globals.node }, + sourceType: 'module', }, }, ]) diff --git a/frontend/index.html b/frontend/index.html index c20fbd3a..65fbb3d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,18 @@ - - - frontend + + VishwaGuru — Report civic issues + + + + + + + + + +
diff --git a/frontend/jest.config.js b/frontend/jest.config.js index d7d6726d..e1281be9 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -4,8 +4,12 @@ export default { moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', '\\.(jpg|jpeg|png|gif|svg)$': '/src/__mocks__/fileMock.js', - '^\\./client$': '/src/__mocks__/client.js', - '^../client$': '/src/__mocks__/client.js', + // No global redirect for ./client. It pointed every importer at + // src/__mocks__/client.js, so api/__tests__/client.test.js asserted + // against a hand-written fixture instead of the real client -- eleven + // tests that could not fail if the client broke. Suites that do want a + // stub call jest.mock('../client', ...) with their own factory, and + // import.meta.env is handled by babel-plugin-transform-vite-meta-env. '^\\./location$': '/src/__mocks__/location.js', '^../location$': '/src/__mocks__/location.js' }, diff --git a/frontend/jest.transform.js b/frontend/jest.transform.js index 42c75a6a..adebbbcf 100644 --- a/frontend/jest.transform.js +++ b/frontend/jest.transform.js @@ -1,5 +1,5 @@ export default { - process(src, filename) { + process(src) { // Replace import.meta.env with a mock object return src.replace(/import\.meta\.env/g, '({ VITE_API_URL: "http://localhost:3000" })'); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b03d19ad..ad1af99d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@capacitor/android": "^8.5.0", + "@capacitor/app": "^8.1.1", + "@capacitor/camera": "^8.2.2", + "@capacitor/core": "^8.5.0", + "@capacitor/geolocation": "^8.2.2", + "@capacitor/network": "^8.0.1", + "@capacitor/splash-screen": "^8.0.2", + "@capacitor/status-bar": "^8.0.3", "dexie": "^4.0.8", "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.0.7", @@ -22,6 +30,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-react": "^7.24.0", + "@capacitor/cli": "^8.5.0", "@eslint/js": "^9.39.1", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^16.0.0", @@ -42,6 +51,7 @@ "msw": "^2.3.0", "postcss": "^8.5.25", "tailwindcss": "^3.4.1", + "typescript": "^6.0.3", "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0" }, @@ -1962,6 +1972,179 @@ "dev": true, "license": "MIT" }, + "node_modules/@capacitor/android": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.0.tgz", + "integrity": "sha512-Rb3prJeQiTp0pQhSSOReJuG9VgibOGMG4ECs+UhMwr6TCCHZ3NCZO811bDA0HxD0xrT/gCrJAsY5yfEATu84JQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz", + "integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/camera": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.2.tgz", + "integrity": "sha512-WfaOsqrjPzvXGvrIBFqFtOZHBrc3hwGKssZje4ioJDNfTjIq2zrUNDXP+q6XmLyzifCYOu2YFC6k1+leb/BqSg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.0.tgz", + "integrity": "sha512-rLdzMUM5QV4WITcqoWv04p32i14BXgUH2diqEH6MWQlWaJfiyNrvOyt/+d5vHAfOxOm1klBu637VDEobctlwBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xcode": "^3.0.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@capacitor/cli/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/cli/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", + "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/geolocation": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.2.tgz", + "integrity": "sha512-X8ryw6CfL4LspJnnfVL4++DUKKZVfdUIA9JljWVRljEDIQxZQZtz6xf7qstht+ymc+le6oa5EaUChVA6KsCYlA==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/network": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/network/-/network-8.0.1.tgz", + "integrity": "sha512-9xK/FHFmzKGanB6BdoSZOzXk8vF0OFVQSQ4PAsIrzAzLuXHryO317qy8dcHVpgxYeuZq2noI0My9z1DvVDi/9w==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/splash-screen": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.2.tgz", + "integrity": "sha512-0C6rYScr13WfAE9nPl4ScOQynmjFv9NT3Th+RZkD4ezYHmER6mcl1/lDYsv7jg3Rj3FOvLrlTlmNkUrr83kZzQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", + "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -2532,9 +2715,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -2757,6 +2940,138 @@ } } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2767,6 +3082,19 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3532,9 +3860,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3549,9 +3874,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3566,9 +3888,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3583,9 +3902,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3600,9 +3916,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3617,9 +3930,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3634,9 +3944,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3651,9 +3958,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3668,9 +3972,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3685,9 +3986,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3702,9 +4000,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3719,9 +4014,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3736,9 +4028,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4032,6 +4321,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -4135,6 +4434,13 @@ "@types/node": "*" } }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4201,6 +4507,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.11.tgz", + "integrity": "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -4415,6 +4731,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4661,6 +4987,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -4674,6 +5021,16 @@ "node": ">=6.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4687,10 +5044,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4755,6 +5135,16 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4928,6 +5318,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -5333,6 +5733,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -5471,6 +5881,19 @@ "dev": true, "license": "ISC" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -5504,6 +5927,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -6191,6 +6624,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6215,9 +6658,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -7032,6 +7475,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7202,6 +7655,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -7545,8 +8014,21 @@ "engines": { "node": ">= 0.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/isarray": { @@ -8625,9 +9107,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -9055,6 +9537,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9159,9 +9654,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -9177,6 +9672,32 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9321,6 +9842,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9542,6 +10081,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9595,6 +10141,21 @@ "node": ">=8" } }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10033,6 +10594,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -10262,6 +10838,83 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -10351,6 +11004,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -10393,6 +11067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -10594,6 +11275,31 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10611,6 +11317,24 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/smob": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", @@ -10652,6 +11376,16 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10706,6 +11440,16 @@ "node": ">= 0.4" } }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -10713,6 +11457,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -11021,6 +11775,33 @@ "node": ">=14.0.0" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -11138,6 +11919,16 @@ "node": ">=0.8" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -11255,6 +12046,16 @@ "node": ">=12" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -11262,6 +12063,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11376,6 +12183,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -11479,6 +12300,16 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -11558,6 +12389,17 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -12020,16 +12862,16 @@ } }, "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/workbox-build/node_modules/glob": { @@ -12334,6 +13176,20 @@ } } }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -12344,6 +13200,40 @@ "node": ">=12" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -12397,6 +13287,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e94a37fa..bc348695 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,15 +13,28 @@ "preview": "vite preview", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "mobile:sync": "npm run build && cap sync android", + "mobile:open": "cap open android", + "mobile:apk": "npm run mobile:sync && cd android && gradlew.bat assembleRelease", + "mobile:aab": "npm run mobile:sync && cd android && gradlew.bat bundleRelease", + "mobile:assets": "capacitor-assets generate --android" }, "dependencies": { + "@capacitor/android": "^8.5.0", + "@capacitor/app": "^8.1.1", + "@capacitor/camera": "^8.2.2", + "@capacitor/core": "^8.5.0", + "@capacitor/geolocation": "^8.2.2", + "@capacitor/network": "^8.0.1", + "@capacitor/splash-screen": "^8.0.2", + "@capacitor/status-bar": "^8.0.3", + "dexie": "^4.0.8", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.0.7", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "i18next": "^25.2.1", - "dexie": "^4.0.8", - "i18next-browser-languagedetector": "^8.0.7", "react-i18next": "^16.5.3", "react-router-dom": "^7.18.2", "react-webcam": "^7.2.0" @@ -30,6 +43,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-react": "^7.24.0", + "@capacitor/cli": "^8.5.0", "@eslint/js": "^9.39.1", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^16.0.0", @@ -50,6 +64,7 @@ "msw": "^2.3.0", "postcss": "^8.5.25", "tailwindcss": "^3.4.1", + "typescript": "^6.0.3", "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0" } diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..5dd2afbe Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..a152d082 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 00000000..cc03cf4f Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 00000000..7011e760 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-96.png b/frontend/public/icon-96.png new file mode 100644 index 00000000..2e0a8324 Binary files /dev/null and b/frontend/public/icon-96.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 00000000..f1f94cf8 Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 1824daf9..d5adab83 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -4,20 +4,36 @@ "description": "Report civic issues with AI-powered smart scanning", "start_url": "/", "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#000000", + "background_color": "#0D1117", + "theme_color": "#0D1117", "orientation": "portrait-primary", "scope": "/", + "categories": ["government", "utilities", "social"], + "lang": "en-IN", "icons": [ + { + "src": "/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any" + }, { "src": "/icon-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] -} \ No newline at end of file +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7bad226e..87b0bb91 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, Suspense } from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; import ChatWidget from './components/ChatWidget'; import { issuesApi, miscApi } from './api'; import { fakeRecentIssues, fakeResponsibilityMap } from './fakeData'; @@ -13,23 +13,37 @@ const MaharashtraRepView = React.lazy(() => import('./views/MaharashtraRepView') const NotFound = React.lazy(() => import('./views/NotFound')); // Lazy-load detector components -const PotholeDetector = React.lazy(() => import('./features/detectors/PotholeDetector')); -const GarbageDetector = React.lazy(() => import('./features/detectors/GarbageDetector')); -const VandalismDetector = React.lazy(() => import('./features/detectors/VandalismDetector')); -const FloodDetector = React.lazy(() => import('./features/detectors/FloodDetector')); -const InfrastructureDetector = React.lazy(() => import('./features/detectors/InfrastructureDetector')); -const IllegalParkingDetector = React.lazy(() => import('./features/detectors/IllegalParkingDetector')); -const StreetLightDetector = React.lazy(() => import('./features/detectors/StreetLightDetector')); -const FireDetector = React.lazy(() => import('./features/detectors/FireDetector')); -const StrayAnimalDetector = React.lazy(() => import('./features/detectors/StrayAnimalDetector')); -const BlockedRoadDetector = React.lazy(() => import('./features/detectors/BlockedRoadDetector')); -const TreeDetector = React.lazy(() => import('./features/detectors/TreeDetector')); +const PotholeDetector = React.lazy(() => import('./PotholeDetector')); +const GarbageDetector = React.lazy(() => import('./GarbageDetector')); +const VandalismDetector = React.lazy(() => import('./VandalismDetector')); +const FloodDetector = React.lazy(() => import('./FloodDetector')); +const InfrastructureDetector = React.lazy(() => import('./InfrastructureDetector')); +const IllegalParkingDetector = React.lazy(() => import('./IllegalParkingDetector')); +const StreetLightDetector = React.lazy(() => import('./StreetLightDetector')); +const FireDetector = React.lazy(() => import('./FireDetector')); +const StrayAnimalDetector = React.lazy(() => import('./StrayAnimalDetector')); +const BlockedRoadDetector = React.lazy(() => import('./BlockedRoadDetector')); +const TreeDetector = React.lazy(() => import('./TreeDetector')); +// These eight were fully written but never routed, so nothing in the app could +// reach them. Their backend endpoints exist now, so they are wired up. +// +const AccessibilityDetector = React.lazy(() => import('./AccessibilityDetector')); +const CivicEyeDetector = React.lazy(() => import('./CivicEyeDetector')); +const CrowdDetector = React.lazy(() => import('./CrowdDetector')); +const NoiseDetector = React.lazy(() => import('./NoiseDetector')); +const PestDetector = React.lazy(() => import('./PestDetector')); +const SeverityDetector = React.lazy(() => import('./SeverityDetector')); +const WasteDetector = React.lazy(() => import('./WasteDetector')); +const WaterLeakDetector = React.lazy(() => import('./WaterLeakDetector')); +const SmartScanner = React.lazy(() => import('./SmartScanner')); // ─── Valid view paths for navigation safety ──────────────────────────────────── const VALID_VIEWS = [ 'home', 'map', 'report', 'action', 'mh-rep', 'pothole', 'garbage', 'vandalism', 'flood', 'infrastructure', - 'parking', 'streetlight', 'fire', 'animal', 'blocked', 'tree' + 'parking', 'streetlight', 'fire', 'animal', 'blocked', 'tree', + 'accessibility', 'civic-eye', 'crowd', 'noise', 'pest', 'severity', + 'waste', 'water-leak', 'smart-scan' ]; // ─── Enhanced header component with animated gradient ────────────────────────── @@ -255,7 +269,11 @@ function AppContent() { -
+ {/* pb-40 clears the fixed buttons stacked in the bottom-right corner + (quick actions, chat, scroll-to-top). Without it the last row of the + home grid sits underneath them and cannot be tapped -- "Report Issue" + was unreachable on a 1080x2400 screen. */} +
{/* Alert banners */} @@ -337,6 +355,15 @@ element={ navigate('/')} />} /> navigate('/')} />} /> navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> + navigate('/')} />} /> } /> diff --git a/frontend/src/BlockedRoadDetector.jsx b/frontend/src/BlockedRoadDetector.jsx index 28e24bc7..da3deb25 100644 --- a/frontend/src/BlockedRoadDetector.jsx +++ b/frontend/src/BlockedRoadDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const BlockedRoadDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -31,7 +36,7 @@ const BlockedRoadDetector = ({ onBack }) => { const formData = new FormData(); formData.append('image', file); - const response = await fetch('/api/detect-blocked-road', { + const response = await fetch(`${API_URL}/api/detect-blocked-road`, { method: 'POST', body: formData, }); @@ -75,7 +80,7 @@ const BlockedRoadDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/CivicEyeDetector.jsx b/frontend/src/CivicEyeDetector.jsx index 5113d12c..5b9c0539 100644 --- a/frontend/src/CivicEyeDetector.jsx +++ b/frontend/src/CivicEyeDetector.jsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { Camera, Eye, Activity, Shield, Sparkles, MapPin, RefreshCw, AlertTriangle } from 'lucide-react'; import { detectorsApi } from './api'; -const CivicEyeDetector = ({ onBack }) => { +const CivicEyeDetector = ({ onBack: _onBack }) => { const videoRef = useRef(null); const canvasRef = useRef(null); const [stream, setStream] = useState(null); diff --git a/frontend/src/FireDetector.jsx b/frontend/src/FireDetector.jsx index e3a5b37f..d2d6c8f1 100644 --- a/frontend/src/FireDetector.jsx +++ b/frontend/src/FireDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const FireDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -31,7 +36,7 @@ const FireDetector = ({ onBack }) => { const formData = new FormData(); formData.append('image', file); - const response = await fetch('/api/detect-fire', { + const response = await fetch(`${API_URL}/api/detect-fire`, { method: 'POST', body: formData, }); @@ -75,7 +80,7 @@ const FireDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/GarbageDetector.jsx b/frontend/src/GarbageDetector.jsx index 9f6ab865..87df3ccc 100644 --- a/frontend/src/GarbageDetector.jsx +++ b/frontend/src/GarbageDetector.jsx @@ -6,7 +6,6 @@ const GarbageDetector = ({ onBack }) => { const videoRef = useRef(null); const canvasRef = useRef(null); const [isDetecting, setIsDetecting] = useState(false); - const [detections, setDetections] = useState([]); const [error, setError] = useState(null); useEffect(() => { diff --git a/frontend/src/IllegalParkingDetector.jsx b/frontend/src/IllegalParkingDetector.jsx index d5d4cb7d..9229d5bf 100644 --- a/frontend/src/IllegalParkingDetector.jsx +++ b/frontend/src/IllegalParkingDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const IllegalParkingDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -33,7 +38,7 @@ const IllegalParkingDetector = ({ onBack }) => { formData.append('image', file); // Call Backend API - const response = await fetch('/api/detect-illegal-parking', { + const response = await fetch(`${API_URL}/api/detect-illegal-parking`, { method: 'POST', body: formData, }); @@ -77,7 +82,7 @@ const IllegalParkingDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/PestDetector.jsx b/frontend/src/PestDetector.jsx index f4b461e4..ebfb0db2 100644 --- a/frontend/src/PestDetector.jsx +++ b/frontend/src/PestDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const PestDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -31,7 +36,7 @@ const PestDetector = ({ onBack }) => { const formData = new FormData(); formData.append('image', file); - const response = await fetch('/api/detect-pest', { + const response = await fetch(`${API_URL}/api/detect-pest`, { method: 'POST', body: formData, }); @@ -77,7 +82,7 @@ const PestDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/PotholeDetector.jsx b/frontend/src/PotholeDetector.jsx index f66e94e1..5db36209 100644 --- a/frontend/src/PotholeDetector.jsx +++ b/frontend/src/PotholeDetector.jsx @@ -6,7 +6,6 @@ const PotholeDetector = ({ onBack }) => { const videoRef = useRef(null); const canvasRef = useRef(null); const [isDetecting, setIsDetecting] = useState(false); - const [detections, setDetections] = useState([]); const [error, setError] = useState(null); useEffect(() => { @@ -98,7 +97,7 @@ const PotholeDetector = ({ onBack }) => { // We only update boxes on success to avoid flickering // Note: This is async, so the video might have moved on. // This is "laggy overlay" but simplest for backend approach. - drawDetections(data.detections, context, video); + drawDetections(data.detections, context); } } catch (err) { console.error("Detection error:", err); @@ -106,7 +105,7 @@ const PotholeDetector = ({ onBack }) => { }, 'image/jpeg', 0.8); }; - const drawDetections = (detections, context, video) => { + const drawDetections = (detections, context) => { // Redraw current video frame so boxes are on top of *latest* video? // No, if we redraw latest video, the boxes might be misaligned if camera moved. // Ideally we freeze frame or just draw on top of live video (augmented reality style). diff --git a/frontend/src/SmartScanner.jsx b/frontend/src/SmartScanner.jsx index 273c0fa9..69270d06 100644 --- a/frontend/src/SmartScanner.jsx +++ b/frontend/src/SmartScanner.jsx @@ -1,7 +1,5 @@ import React, { useRef, useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import * as tf from '@tensorflow/tfjs'; -import * as mobilenet from '@tensorflow-models/mobilenet'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -11,24 +9,10 @@ const SmartScanner = ({ onBack }) => { const [isDetecting, setIsDetecting] = useState(false); const [detection, setDetection] = useState(null); const [error, setError] = useState(null); - const [model, setModel] = useState(null); const [previousFrame, setPreviousFrame] = useState(null); const lastSentRef = useRef(0); const navigate = useNavigate(); - useEffect(() => { - const loadModel = async () => { - try { - await tf.ready(); - const loadedModel = await mobilenet.load(); - setModel(loadedModel); - } catch (err) { - console.error('Failed to load model:', err); - } - }; - loadModel(); - }, []); - useEffect(() => { let interval; if (isDetecting) { @@ -83,7 +67,7 @@ const SmartScanner = ({ onBack }) => { }; const detectFrame = async () => { - if (!videoRef.current || !canvasRef.current || !isDetecting || !model) return; + if (!videoRef.current || !canvasRef.current || !isDetecting) return; const video = videoRef.current; if (video.readyState !== 4) return; @@ -117,14 +101,13 @@ const SmartScanner = ({ onBack }) => { return; } - // Run local inference - const predictions = await model.classify(canvas); - const topPrediction = predictions[0]; - - // If frame changed and local model has high confidence, send to backend - if (topPrediction.probability > 0.5) { - lastSentRef.current = now; // Update timestamp - // Proceed to backend detection + // Upload throttling is handled by the frame-difference check above and + // the two-second cooldown. This used to also run MobileNet in the page + // as a confidence gate, which meant shipping a model and the TensorFlow + // runtime to a device on a metered connection to decide whether to make + // a request the backend classifies properly anyway. + { + lastSentRef.current = now; canvas.toBlob(async (blob) => { if (!blob) return; @@ -145,9 +128,6 @@ const SmartScanner = ({ onBack }) => { console.error("Detection error:", err); } }, 'image/jpeg', 0.8); - } else { - // Local detection: low confidence, consider safe - setDetection({ label: 'Safe', score: topPrediction.probability }); } }; diff --git a/frontend/src/StrayAnimalDetector.jsx b/frontend/src/StrayAnimalDetector.jsx index 68a3e05b..1ad4ac2c 100644 --- a/frontend/src/StrayAnimalDetector.jsx +++ b/frontend/src/StrayAnimalDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const StrayAnimalDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -31,7 +36,7 @@ const StrayAnimalDetector = ({ onBack }) => { const formData = new FormData(); formData.append('image', file); - const response = await fetch('/api/detect-stray-animal', { + const response = await fetch(`${API_URL}/api/detect-stray-animal`, { method: 'POST', body: formData, }); @@ -75,7 +80,7 @@ const StrayAnimalDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/StreetLightDetector.jsx b/frontend/src/StreetLightDetector.jsx index 1a11e2cb..49848908 100644 --- a/frontend/src/StreetLightDetector.jsx +++ b/frontend/src/StreetLightDetector.jsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const StreetLightDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -31,7 +36,7 @@ const StreetLightDetector = ({ onBack }) => { const formData = new FormData(); formData.append('image', file); - const response = await fetch('/api/detect-street-light', { + const response = await fetch(`${API_URL}/api/detect-street-light`, { method: 'POST', body: formData, }); @@ -75,7 +80,7 @@ const StreetLightDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/TreeDetector.jsx b/frontend/src/TreeDetector.jsx index d776dbce..15253ce0 100644 --- a/frontend/src/TreeDetector.jsx +++ b/frontend/src/TreeDetector.jsx @@ -1,6 +1,11 @@ import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; +// Relative '/api/...' only resolved via Vite's dev proxy and Netlify's +// redirect. Neither exists inside a Capacitor WebView, where the page is +// served from capacitor://localhost, so the request had no backend to reach. +const API_URL = import.meta.env.VITE_API_URL || ''; + const TreeDetector = ({ onBack }) => { const webcamRef = useRef(null); const [imgSrc, setImgSrc] = useState(null); @@ -33,7 +38,7 @@ const TreeDetector = ({ onBack }) => { formData.append('image', file); // Call Backend API - const response = await fetch('/api/detect-tree-hazard', { + const response = await fetch(`${API_URL}/api/detect-tree-hazard`, { method: 'POST', body: formData, }); @@ -76,7 +81,7 @@ const TreeDetector = ({ onBack }) => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/VandalismDetector.jsx b/frontend/src/VandalismDetector.jsx index 608f2972..8fb33349 100644 --- a/frontend/src/VandalismDetector.jsx +++ b/frontend/src/VandalismDetector.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import Webcam from 'react-webcam'; import { detectorsApi } from './api/detectors'; @@ -65,7 +65,7 @@ const VandalismDetector = () => { ref={webcamRef} screenshotFormat="image/jpeg" className="w-full h-full object-cover" - onUserMediaError={(err) => setCameraError("Could not access camera. Please check permissions.")} + onUserMediaError={() => setCameraError("Could not access camera. Please check permissions.")} /> ) : (
diff --git a/frontend/src/WasteDetector.jsx b/frontend/src/WasteDetector.jsx index 8760cfe8..cdb05574 100644 --- a/frontend/src/WasteDetector.jsx +++ b/frontend/src/WasteDetector.jsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { Camera, RefreshCw, ArrowRight, Info, CheckCircle, Trash2 } from 'lucide-react'; import { detectorsApi } from './api'; -const WasteDetector = ({ onBack }) => { +const WasteDetector = ({ onBack: _onBack }) => { const videoRef = useRef(null); const canvasRef = useRef(null); const [stream, setStream] = useState(null); diff --git a/frontend/src/__mocks__/client.js b/frontend/src/__mocks__/client.js deleted file mode 100644 index 8e2697a0..00000000 --- a/frontend/src/__mocks__/client.js +++ /dev/null @@ -1,52 +0,0 @@ -// Mock version of client.js for testing -const getApiUrl = () => { - return process.env.VITE_API_URL || ''; -}; - -const makeRequest = async (url, options = {}) => { - const apiUrl = getApiUrl(); - const fullUrl = apiUrl ? `${apiUrl}${url}` : url; - const response = await fetch(fullUrl, { - headers: { - 'Content-Type': 'application/json', - ...options.headers - }, - ...options - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); -}; - -export const apiClient = { - get: (url) => makeRequest(url), - post: (url, data) => makeRequest(url, { - method: 'POST', - body: JSON.stringify(data) - }), - put: (url, data) => makeRequest(url, { - method: 'PUT', - body: JSON.stringify(data) - }), - delete: (url) => makeRequest(url, { - method: 'DELETE' - }), - postForm: (url, formData) => { - const apiUrl = getApiUrl(); - const fullUrl = apiUrl ? `${apiUrl}${url}` : url; - return fetch(fullUrl, { - method: 'POST', - body: formData - }).then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return response.json(); - }); - } -}; - -export { getApiUrl }; \ No newline at end of file diff --git a/frontend/src/api/__tests__/client.test.js b/frontend/src/api/__tests__/client.test.js index c61a2a40..3303fd3c 100644 --- a/frontend/src/api/__tests__/client.test.js +++ b/frontend/src/api/__tests__/client.test.js @@ -1,177 +1,145 @@ -import { apiClient, getApiUrl } from '../client'; - -// Mock fetch globally -global.fetch = jest.fn(); - -describe('apiClient', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset environment variable - delete process.env.VITE_API_URL; +/** + * Tests for the real API client. + * + * This file previously imported '../client', which jest.config.js redirected to + * src/__mocks__/client.js. Every assertion here described a hand-written + * fixture rather than the client the application ships, so the suite could not + * have failed if the client broke -- and it encoded the fixture's behaviour + * (reading process.env, sending a JSON Content-Type on GET) rather than the + * client's. The redirect is gone; these now exercise the real module. + * + * Retry, timeout and cold-start behaviour live in retry.test.js. + */ +import { apiClient, getApiUrl, retryConfig } from '../client'; + +// babel-plugin-transform-vite-meta-env replaces import.meta.env at transform +// time, so the base URL is fixed for the whole run rather than read from the +// environment. Assertions therefore go through getApiUrl() instead of assuming +// a value. +const BASE = getApiUrl(); + +const original = { ...retryConfig }; + +beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + // Keep failure cases from spending real backoff time. + retryConfig.totalBudgetMs = 200; + retryConfig.backoffMs = [1]; +}); + +afterEach(() => { + Object.assign(retryConfig, original); +}); + +const jsonResponse = (body, { ok = true, status = 200 } = {}) => ({ + ok, + status, + json: jest.fn().mockResolvedValue(body), +}); + +/** The URL and options fetch was actually called with. */ +const lastCall = () => global.fetch.mock.calls[global.fetch.mock.calls.length - 1]; + +describe('getApiUrl', () => { + it('returns the configured base URL', () => { + expect(typeof getApiUrl()).toBe('string'); }); +}); - describe('getApiUrl', () => { - it('should return empty string when VITE_API_URL is not set', () => { - expect(getApiUrl()).toBe(''); - }); +describe('get', () => { + it('returns the decoded body on success', async () => { + global.fetch.mockResolvedValue(jsonResponse({ data: 'test' })); - it('should return the VITE_API_URL when set', () => { - process.env.VITE_API_URL = 'https://api.example.com'; - expect(getApiUrl()).toBe('https://api.example.com'); - }); + await expect(apiClient.get('/test-endpoint')).resolves.toEqual({ data: 'test' }); }); - describe('get', () => { - it('should make a GET request and return JSON data on success', async () => { - const mockResponse = { data: 'test' }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; + it('requests the endpoint under the configured base URL', async () => { + global.fetch.mockResolvedValue(jsonResponse({})); - global.fetch.mockResolvedValue(mockFetchResponse); + await apiClient.get('/api/stats'); - const result = await apiClient.get('/test-endpoint'); + expect(lastCall()[0]).toBe(`${BASE}/api/stats`); + }); - expect(global.fetch).toHaveBeenCalledWith('/test-endpoint', { - headers: { - 'Content-Type': 'application/json' - } - }); - expect(result).toEqual(mockResponse); - }); + it('does not send a JSON Content-Type on a request with no body', async () => { + global.fetch.mockResolvedValue(jsonResponse({})); - it('should throw an error when response is not ok', async () => { - const mockFetchResponse = { - ok: false, - status: 404 - }; + await apiClient.get('/api/stats'); - global.fetch.mockResolvedValue(mockFetchResponse); + expect(lastCall()[1].headers).toBeUndefined(); + }); - await expect(apiClient.get('/test-endpoint')).rejects.toThrow('HTTP error! status: 404'); - }); + it('attaches an abort signal so a request cannot hang forever', async () => { + global.fetch.mockResolvedValue(jsonResponse({})); - it('should use the API URL prefix when VITE_API_URL is set', async () => { - process.env.VITE_API_URL = 'https://api.example.com'; - const mockResponse = { data: 'test' }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; + await apiClient.get('/api/stats'); - global.fetch.mockResolvedValue(mockFetchResponse); + // The absence of this is what let cold-start requests hang indefinitely. + expect(lastCall()[1].signal).toBeDefined(); + }); - await apiClient.get('/test-endpoint'); + it('throws when the response is not ok', async () => { + global.fetch.mockResolvedValue(jsonResponse({}, { ok: false, status: 404 })); - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/test-endpoint', { - headers: { - 'Content-Type': 'application/json' - } - }); - }); + await expect(apiClient.get('/missing')).rejects.toThrow('404'); }); +}); - describe('post', () => { - it('should make a POST request with JSON data and return response', async () => { - const mockResponse = { success: true }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; - - global.fetch.mockResolvedValue(mockFetchResponse); - - const testData = { name: 'test' }; - const result = await apiClient.post('/test-endpoint', testData); - - expect(global.fetch).toHaveBeenCalledWith('/test-endpoint', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(testData), - }); - expect(result).toEqual(mockResponse); - }); - - it('should throw an error when POST response is not ok', async () => { - const mockFetchResponse = { - ok: false, - status: 500 - }; +describe('post', () => { + it('sends JSON and returns the decoded body', async () => { + global.fetch.mockResolvedValue(jsonResponse({ id: 1 })); - global.fetch.mockResolvedValue(mockFetchResponse); + await expect(apiClient.post('/api/chat', { query: 'hello' })).resolves.toEqual({ id: 1 }); - await expect(apiClient.post('/test-endpoint', {})).rejects.toThrow('HTTP error! status: 500'); - }); - - it('should use the API URL prefix for POST requests', async () => { - process.env.VITE_API_URL = 'https://api.example.com'; - const mockResponse = { success: true }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; + const [, options] = lastCall(); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(JSON.parse(options.body)).toEqual({ query: 'hello' }); + }); - global.fetch.mockResolvedValue(mockFetchResponse); + it('posts to the endpoint under the configured base URL', async () => { + global.fetch.mockResolvedValue(jsonResponse({})); - await apiClient.post('/test-endpoint', {}); + await apiClient.post('/api/issues', {}); - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/test-endpoint', expect.any(Object)); - }); + expect(lastCall()[0]).toBe(`${BASE}/api/issues`); }); - describe('postForm', () => { - it('should make a POST request with FormData and return response', async () => { - const mockResponse = { success: true }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; + it('throws when the response is not ok', async () => { + global.fetch.mockResolvedValue(jsonResponse({}, { ok: false, status: 422 })); - global.fetch.mockResolvedValue(mockFetchResponse); - - const formData = new FormData(); - formData.append('file', new Blob(['test']), 'test.txt'); + await expect(apiClient.post('/api/chat', {})).rejects.toThrow('422'); + }); +}); - const result = await apiClient.postForm('/upload-endpoint', formData); +describe('postForm', () => { + it('sends the FormData unchanged and returns the decoded body', async () => { + global.fetch.mockResolvedValue(jsonResponse({ detections: [] })); + const form = new FormData(); + form.append('image', 'blob'); - expect(global.fetch).toHaveBeenCalledWith('/upload-endpoint', { - method: 'POST', - body: formData, - }); - expect(result).toEqual(mockResponse); + await expect(apiClient.postForm('/api/detect-pothole', form)).resolves.toEqual({ + detections: [], }); - it('should throw an error when FormData POST response is not ok', async () => { - const mockFetchResponse = { - ok: false, - status: 400 - }; - - global.fetch.mockResolvedValue(mockFetchResponse); - - const formData = new FormData(); - - await expect(apiClient.postForm('/upload-endpoint', formData)).rejects.toThrow('HTTP error! status: 400'); - }); + const [, options] = lastCall(); + expect(options.method).toBe('POST'); + expect(options.body).toBe(form); + }); - it('should use the API URL prefix for FormData POST requests', async () => { - process.env.VITE_API_URL = 'https://api.example.com'; - const mockResponse = { success: true }; - const mockFetchResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockResponse) - }; + it('does not set Content-Type, so fetch can add the multipart boundary', async () => { + global.fetch.mockResolvedValue(jsonResponse({})); - global.fetch.mockResolvedValue(mockFetchResponse); + await apiClient.postForm('/api/detect-pothole', new FormData()); - const formData = new FormData(); + // Setting it by hand omits the boundary and the server rejects the upload. + expect(lastCall()[1].headers).toBeUndefined(); + }); - await apiClient.postForm('/upload-endpoint', formData); + it('throws when the response is not ok', async () => { + global.fetch.mockResolvedValue(jsonResponse({}, { ok: false, status: 413 })); - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/upload-endpoint', expect.any(Object)); - }); + await expect(apiClient.postForm('/api/detect-pothole', new FormData())).rejects.toThrow('413'); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/api/__tests__/issues.test.js b/frontend/src/api/__tests__/issues.test.js index 36fb22bf..4430b15d 100644 --- a/frontend/src/api/__tests__/issues.test.js +++ b/frontend/src/api/__tests__/issues.test.js @@ -120,7 +120,7 @@ describe('issuesApi', () => { const result = await issuesApi.vote(issueId); - expect(apiClient.post).toHaveBeenCalledWith('/api/issues/123/vote', {}); + expect(apiClient.post).toHaveBeenCalledWith('/api/issues/123/upvote', {}); expect(result).toEqual(mockResponse); }); @@ -132,7 +132,7 @@ describe('issuesApi', () => { await issuesApi.vote(issueId); - expect(apiClient.post).toHaveBeenCalledWith(`/api/issues/${issueId}/vote`, {}); + expect(apiClient.post).toHaveBeenCalledWith(`/api/issues/${issueId}/upvote`, {}); } }); diff --git a/frontend/src/api/__tests__/retry.test.js b/frontend/src/api/__tests__/retry.test.js new file mode 100644 index 00000000..957eb96c --- /dev/null +++ b/frontend/src/api/__tests__/retry.test.js @@ -0,0 +1,139 @@ +/** + * Cold-start resilience for the API client. + * + * The backend is deployed on an instance that suspends when idle. Its first + * request after a quiet period took 117 seconds when measured against the live + * service; subsequent requests took 0.5s. Every call used a bare fetch with no + * timeout and no retry, so during that window requests hung indefinitely, the + * UI showed an unexplained spinner, and detectors polling every two seconds + * stacked up dozens of pending requests against a server that was still + * starting. + */ +import { apiClient, onServerWaking, request, retryConfig } from '../client'; + +const original = { ...retryConfig }; + +beforeEach(() => { + jest.clearAllMocks(); + // Shrink the delays; retrying through real backoff would spend a minute of + // CI time proving arithmetic. + retryConfig.attemptTimeoutMs = 50; + retryConfig.totalBudgetMs = 400; + retryConfig.backoffMs = [1]; +}); + +afterEach(() => { + Object.assign(retryConfig, original); +}); + +const ok = (body = {}) => ({ ok: true, status: 200, json: async () => body }); +const serverError = () => ({ ok: false, status: 500, json: async () => ({}) }); +const clientError = (status = 422) => ({ ok: false, status, json: async () => ({}) }); + +describe('transient failures', () => { + it('retries and succeeds once the server finishes waking', async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error('Network request failed')) + .mockRejectedValueOnce(new Error('Network request failed')) + .mockResolvedValueOnce(ok({ status: 'healthy' })); + + await expect(apiClient.get('/health')).resolves.toEqual({ status: 'healthy' }); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it('retries a 500, which is what a starting server returns', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(serverError()) + .mockResolvedValueOnce(ok({ ready: true })); + + await expect(apiClient.get('/api/stats')).resolves.toEqual({ ready: true }); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('gives up once the budget is spent instead of hanging forever', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network request failed')); + + await expect(apiClient.get('/health')).rejects.toThrow(); + // The point is that it terminates at all; the old client had no bound. + expect(global.fetch).toHaveBeenCalled(); + }); +}); + +describe('failures that must not be retried', () => { + it('does not retry a 4xx', async () => { + // A 422 means the request itself is wrong. Retrying it burns the budget + // and, on the AI endpoints, real money. + global.fetch = jest.fn().mockResolvedValue(clientError(422)); + + await expect(apiClient.post('/api/chat', { query: 'hi' })).rejects.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('honours retry: false for calls where a stale answer is useless', async () => { + // A live detector frame is pointless thirty seconds later. + global.fetch = jest.fn().mockRejectedValue(new Error('Network request failed')); + + await expect( + request('/api/detect-pothole', { method: 'POST' }, { retry: false }), + ).rejects.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('timeout', () => { + it('aborts an attempt that never settles rather than waiting forever', async () => { + global.fetch = jest.fn((_url, options) => { + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })), + ); + }); + }); + + await expect(apiClient.get('/health')).rejects.toThrow(); + expect(global.fetch).toHaveBeenCalled(); + }); +}); + +describe('server-waking notifications', () => { + it('tells subscribers the server is waking, then that it is up', async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error('Network request failed')) + .mockResolvedValueOnce(ok({})); + + const seen = []; + const unsubscribe = onServerWaking((waking) => seen.push(waking)); + + await apiClient.get('/health'); + unsubscribe(); + + // Without this the UI cannot distinguish "starting" from "broken". + expect(seen).toContain(true); + expect(seen[seen.length - 1]).toBe(false); + }); + + it('stays quiet when the first attempt succeeds', async () => { + global.fetch = jest.fn().mockResolvedValue(ok({})); + + const seen = []; + const unsubscribe = onServerWaking((waking) => seen.push(waking)); + + await apiClient.get('/health'); + unsubscribe(); + + expect(seen).not.toContain(true); + }); + + it('unsubscribes cleanly', async () => { + global.fetch = jest.fn().mockResolvedValue(ok({})); + + const listener = jest.fn(); + onServerWaking(listener)(); + + await apiClient.get('/health'); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 687ed188..d277429e 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,37 +1,146 @@ const API_URL = import.meta.env.VITE_API_URL || ''; -export const apiClient = { - get: async (endpoint) => { - const response = await fetch(`${API_URL}${endpoint}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); +/** + * The API is deployed on an instance that suspends when idle and takes up to + * ~2 minutes to serve its first request again. Every call in this file + * previously used a bare fetch with no timeout and no retry, so during that + * window requests hung indefinitely, the UI showed a spinner with no + * explanation, and a detector polling every 2 seconds stacked up dozens of + * pending requests against a server that was still starting. + * + * So: each attempt gets a bounded timeout, failures retry with backoff inside a + * total budget long enough to cover a cold start, and callers can subscribe to + * find out that the server is waking rather than broken. + */ +/** + * Mutable so tests can shrink the delays. Retrying through real backoff in a + * unit test spends a minute of CI time proving arithmetic. + */ +export const retryConfig = { + attemptTimeoutMs: 20000, + // Long enough to cover a cold start on a suspended instance. + totalBudgetMs: 150000, + backoffMs: [1000, 3000, 6000, 10000, 15000], +}; + +const wakeListeners = new Set(); + +/** + * Subscribe to server-waking notifications. The callback receives true when a + * request has failed at least once and is being retried, and false once a + * request succeeds. Returns an unsubscribe function. + */ +export const onServerWaking = (listener) => { + wakeListeners.add(listener); + return () => wakeListeners.delete(listener); +}; + +const notifyWaking = (waking) => { + for (const listener of wakeListeners) { + try { + listener(waking); + } catch (err) { + console.error('Server-waking listener failed', err); + } + } +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A 4xx means the request itself is wrong; retrying only wastes the budget. */ +const isRetriable = (error, response) => { + if (response) return response.status >= 500 || response.status === 429; + return true; // network error, abort, DNS failure +}; + +const fetchWithTimeout = async (url, options, timeoutMs) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +}; + +/** + * Perform a request, retrying transient failures within the total budget. + * + * `retry: false` opts out for calls where a stale answer is worse than none -- + * a live detector frame is pointless 30 seconds later. + */ +export const request = async (endpoint, options = {}, { retry = true } = {}) => { + const url = `${API_URL}${endpoint}`; + const deadline = Date.now() + retryConfig.totalBudgetMs; + let attempt = 0; + let lastError; + + for (;;) { + try { + const response = await fetchWithTimeout(url, options, retryConfig.attemptTimeoutMs); + + if (!response.ok) { + const error = new Error(`HTTP error! status: ${response.status}`); + error.status = response.status; + if (!retry || !isRetriable(error, response) || Date.now() >= deadline) { + // Flagged so the catch below re-throws instead of treating this as a + // transient failure. Throwing here without the flag meant a 422 was + // caught by this function's own handler and retried until the budget + // ran out -- 28 requests for a payload the server had already + // rejected, which on the AI endpoints costs real money. + error.noRetry = true; + throw error; + } + lastError = error; + } else { + notifyWaking(false); + return response; + } + } catch (error) { + // A caller-supplied abort is intentional and must not be retried. + if (error.noRetry) throw error; + if (options.signal?.aborted) throw error; + if (!retry || Date.now() >= deadline) throw error; + lastError = error; } + + const { backoffMs } = retryConfig; + const delay = backoffMs[Math.min(attempt, backoffMs.length - 1)]; + if (Date.now() + delay >= deadline) throw lastError; + + // Only announce after the first failure: a single slow request is normal, + // a retry means the server is very likely still starting. + notifyWaking(true); + await sleep(delay); + attempt += 1; + } +}; + +export const apiClient = { + get: async (endpoint, opts) => { + const response = await request(endpoint, {}, opts); return response.json(); }, - post: async (endpoint, data) => { - const response = await fetch(`${API_URL}${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + + post: async (endpoint, data, opts) => { + const response = await request( + endpoint, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), }, - body: JSON.stringify(data), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + opts, + ); + return response.json(); + }, + + // For file uploads (FormData). fetch sets the multipart Content-Type and + // boundary itself, so it must not be set here. + postForm: async (endpoint, formData, opts) => { + const response = await request(endpoint, { method: 'POST', body: formData }, opts); return response.json(); }, - // For file uploads (FormData) - postForm: async (endpoint, formData) => { - const response = await fetch(`${API_URL}${endpoint}`, { - method: 'POST', - body: formData, // fetch automatically sets Content-Type to multipart/form-data with boundary - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return response.json(); - } }; export const getApiUrl = () => API_URL; diff --git a/frontend/src/api/detectors.js b/frontend/src/api/detectors.js index 2749f5ab..7e5e959d 100644 --- a/frontend/src/api/detectors.js +++ b/frontend/src/api/detectors.js @@ -1,4 +1,4 @@ -import { apiClient, getApiUrl } from './client'; +import { apiClient } from './client'; // Helper to create a detector API function const createDetectorApi = (endpoint) => async (data) => { diff --git a/frontend/src/api/issues.js b/frontend/src/api/issues.js index 83a192f6..b591ee1e 100644 --- a/frontend/src/api/issues.js +++ b/frontend/src/api/issues.js @@ -17,6 +17,7 @@ export const issuesApi = { }, vote: async (id) => { - return await apiClient.post(`/api/issues/${id}/vote`, {}); // The backend endpoint might not require a body for upvote + // The served route is /upvote. This called /vote and 404'd on every press. + return await apiClient.post(`/api/issues/${id}/upvote`, {}); } }; diff --git a/frontend/src/components/ChatWidget.jsx b/frontend/src/components/ChatWidget.jsx index e8bdabe0..c04012b8 100644 --- a/frontend/src/components/ChatWidget.jsx +++ b/frontend/src/components/ChatWidget.jsx @@ -53,7 +53,12 @@ const ChatWidget = () => { }; return ( -
+ // Positioning is owned by the parent (EnhancedChatWidget in App.jsx), which + // is itself `fixed`. This element used to be fixed too, at a 16px offset + // against the wrapper's 32px, so the button escaped the wrapper while the + // hover tooltip and the green status dot stayed anchored to it -- they + // rendered detached from the control they describe. +
{/* Chat Window */} {isOpen && (
diff --git a/frontend/src/components/VoiceInput.jsx b/frontend/src/components/VoiceInput.jsx index 53dd455e..34afd1ef 100644 --- a/frontend/src/components/VoiceInput.jsx +++ b/frontend/src/components/VoiceInput.jsx @@ -1,9 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Mic, MicOff, Loader2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; const VoiceInput = ({ onTranscript, language = 'en' }) => { - const { t } = useTranslation(); const [isListening, setIsListening] = useState(false); const [recognition, setRecognition] = useState(null); const [error, setError] = useState(null); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 446f8b07..64c2da1f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -4,9 +4,14 @@ import './index.css' import App from './App.jsx' import './i18n' // Initialize i18n import './offlineQueue' // Initialize offline queue listeners +import { initNativeShell } from './native' // No-op on the web createRoot(document.getElementById('root')).render( , ) + +// The native splash screen stays up until something hides it, so a failure here +// would leave the app on the splash forever. +initNativeShell() diff --git a/frontend/src/native.js b/frontend/src/native.js new file mode 100644 index 00000000..515efa5f --- /dev/null +++ b/frontend/src/native.js @@ -0,0 +1,106 @@ +/** + * Native platform bootstrap for the Capacitor build. + * + * Everything here is a no-op on the web, so the same bundle serves the PWA and + * the packaged app. Inside a WebView, three things do not happen by themselves: + * + * - the splash screen stays up until something dismisses it; + * - the status bar keeps the system default colours and overlaps the layout; + * - `getUserMedia` and `navigator.geolocation` fail without the Android + * runtime permission having been granted, and a plain web denial handler + * cannot request it. + */ +import { Capacitor } from '@capacitor/core'; + +export const isNativePlatform = () => Capacitor.isNativePlatform(); + +/** + * Ask for camera access. Returns true when the app may use the camera. + * + * On the web this resolves true without prompting; the browser prompts when + * getUserMedia is actually called. + */ +export async function ensureCameraPermission() { + if (!Capacitor.isNativePlatform()) return true; + + try { + const { Camera } = await import('@capacitor/camera'); + const status = await Camera.checkPermissions(); + if (status.camera === 'granted') return true; + if (status.camera === 'denied') return false; + + const requested = await Camera.requestPermissions({ permissions: ['camera'] }); + return requested.camera === 'granted'; + } catch (err) { + console.error('Camera permission request failed', err); + return false; + } +} + +/** + * Ask for location access. Returns true when the app may read position. + */ +export async function ensureLocationPermission() { + if (!Capacitor.isNativePlatform()) return true; + + try { + const { Geolocation } = await import('@capacitor/geolocation'); + const status = await Geolocation.checkPermissions(); + if (status.location === 'granted' || status.coarseLocation === 'granted') return true; + + const requested = await Geolocation.requestPermissions(); + return requested.location === 'granted' || requested.coarseLocation === 'granted'; + } catch (err) { + console.error('Location permission request failed', err); + return false; + } +} + +/** + * Read the current position. + * + * Uses the Capacitor plugin natively, because navigator.geolocation in a + * WebView resolves only after the Android permission is granted and gives no + * way to request it. Falls back to the browser API on the web. + */ +export async function getCurrentPosition(options = {}) { + const settings = { enableHighAccuracy: true, timeout: 15000, maximumAge: 0, ...options }; + + if (Capacitor.isNativePlatform()) { + const granted = await ensureLocationPermission(); + if (!granted) throw new Error('Location permission denied'); + + const { Geolocation } = await import('@capacitor/geolocation'); + return Geolocation.getCurrentPosition(settings); + } + + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation is not supported by this browser')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, settings); + }); +} + +/** + * Dismiss the splash screen and colour the status bar. Safe to call anywhere. + */ +export async function initNativeShell() { + if (!Capacitor.isNativePlatform()) return; + + try { + const { StatusBar, Style } = await import('@capacitor/status-bar'); + await StatusBar.setStyle({ style: Style.Dark }); + await StatusBar.setBackgroundColor({ color: '#0D1117' }); + } catch (err) { + console.error('Status bar setup failed', err); + } + + try { + const { SplashScreen } = await import('@capacitor/splash-screen'); + await SplashScreen.hide(); + } catch (err) { + console.error('Splash screen dismissal failed', err); + } +} diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index cf8d193d..d2e76774 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -1,5 +1,18 @@ import '@testing-library/jest-dom'; +// jsdom does not implement TextEncoder/TextDecoder, but react-router v7 needs +// them at import time -- without these, any test that renders a routed +// component dies with "ReferenceError: TextEncoder is not defined" before a +// single assertion runs. This is why the views had no component tests. +import { TextDecoder, TextEncoder } from 'node:util'; + +if (typeof global.TextEncoder === 'undefined') { + global.TextEncoder = TextEncoder; +} +if (typeof global.TextDecoder === 'undefined') { + global.TextDecoder = TextDecoder; +} + // Mock import.meta globally for Jest global.import = global.import || {}; global.import.meta = { diff --git a/frontend/src/views/ActionView.jsx b/frontend/src/views/ActionView.jsx index e8fcb491..42d5f9b9 100644 --- a/frontend/src/views/ActionView.jsx +++ b/frontend/src/views/ActionView.jsx @@ -5,18 +5,13 @@ import StatusTracker from '../components/StatusTracker'; const API_URL = import.meta.env.VITE_API_URL || ''; const ActionView = ({ actionPlan, setActionPlan, setView }) => { - if (!actionPlan) { - return ( -
-

No action plan found

-

Please submit a complaint first to generate an action plan.

- -
- ); - } + // This effect must run before any early return. It previously sat below the + // `if (!actionPlan)` bail-out, so the component called a different number of + // hooks depending on its props -- once actionPlan went from null to set, + // React threw "Rendered more hooks than during the previous render". useEffect(() => { let interval; - if (actionPlan.status === 'generating' && actionPlan.id) { + if (actionPlan && actionPlan.status === 'generating' && actionPlan.id) { interval = setInterval(async () => { try { const res = await fetch(`${API_URL}/api/issues/recent`); @@ -40,6 +35,16 @@ if (issue && issue.action_plan && issue.action_plan.whatsapp) { return () => clearInterval(interval); }, [actionPlan, setActionPlan]); + if (!actionPlan) { + return ( +
+

No action plan found

+

Please submit a complaint first to generate an action plan.

+ +
+ ); + } + if (actionPlan.status === 'generating') { return (
diff --git a/frontend/src/views/GrievanceView.jsx b/frontend/src/views/GrievanceView.jsx index 4262801f..8607ca26 100644 --- a/frontend/src/views/GrievanceView.jsx +++ b/frontend/src/views/GrievanceView.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { grievancesApi } from '../api'; -const GrievanceView = ({ setView }) => { +const GrievanceView = ({ setView: _setView }) => { const [grievances, setGrievances] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); diff --git a/frontend/src/views/Home.jsx b/frontend/src/views/Home.jsx index 4e4e21f6..f2b7bcb1 100644 --- a/frontend/src/views/Home.jsx +++ b/frontend/src/views/Home.jsx @@ -1,11 +1,11 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { useNavigate } from 'react-router-dom'; import { AlertTriangle, MapPin, Search, Activity, Camera, Trash2, ThumbsUp, Brush, Droplets, Zap, Truck, Flame, Dog, XCircle, Lightbulb, TreePine, ChevronRight, ChevronUp, Shield, Monitor, Scan, Trophy, - LayoutGrid, Leaf, Bug, Volume2, Users, Waves, Recycle, Eye, CheckCircle + LayoutGrid, Leaf, Bug, Volume2, Users, Waves, Recycle, Eye, CheckCircle, + Accessibility, Gauge } from 'lucide-react'; // ─── Camera Check Modal ──────────────────────────────────────────────────────── @@ -57,7 +57,6 @@ const CameraCheckModal = ({ onClose }) => { // ─── Home Component ──────────────────────────────────────────────────────────── const Home = ({ setView, fetchResponsibilityMap, recentIssues, handleUpvote }) => { - const navigate = useNavigate(); const [showCameraCheck, setShowCameraCheck] = React.useState(false); const [showScrollTop, setShowScrollTop] = React.useState(false); const totalImpact = 1240 + (recentIssues ? recentIssues.length : 0); @@ -96,6 +95,22 @@ const Home = ({ setView, fetchResponsibilityMap, recentIssues, handleUpvote }) = { id: 'animal', label: 'Stray Animal', icon: , color: 'text-amber-600', bg: 'bg-amber-50' }, { id: 'infrastructure', label: 'Broken Infra', icon: , color: 'text-yellow-600', bg: 'bg-yellow-50' }, { id: 'vandalism', label: 'Graffiti', icon: , color: 'text-indigo-600', bg: 'bg-indigo-50' }, + { id: 'waste', label: 'Waste Type', icon: , color: 'text-lime-600', bg: 'bg-lime-50' }, + { id: 'water-leak', label: 'Water Leak', icon: , color: 'text-sky-600', bg: 'bg-sky-50' }, + { id: 'pest', label: 'Pest / Mosquito', icon: , color: 'text-emerald-600', bg: 'bg-emerald-50' }, + { id: 'noise', label: 'Noise', icon: , color: 'text-fuchsia-600', bg: 'bg-fuchsia-50' }, + ] + }, + { + // These four components were written but never routed or linked, so no + // user could reach them. Their backend endpoints exist now. + title: 'Community & Access', + icon: , + items: [ + { id: 'accessibility', label: 'Accessibility', icon: , color: 'text-blue-600', bg: 'bg-blue-50' }, + { id: 'crowd', label: 'Crowd Density', icon: , color: 'text-violet-600', bg: 'bg-violet-50' }, + { id: 'civic-eye', label: 'Civic Eye', icon: , color: 'text-teal-600', bg: 'bg-teal-50' }, + { id: 'severity', label: 'Severity Check', icon: , color: 'text-red-600', bg: 'bg-red-50' }, ] }, { @@ -133,9 +148,12 @@ const Home = ({ setView, fetchResponsibilityMap, recentIssues, handleUpvote }) =
- {/* Smart Scanner CTA */} + {/* Smart Scanner CTA. + This opened the pothole detector, so the app's most prominent + call to action -- "AI-powered issue detection" -- led somewhere + else entirely, and the Smart Scanner screen was unreachable. */}