Spaces:
Running
Running
Milan Soni commited on
Commit ·
3a7eb07
1
Parent(s): 4d8d7fe
Deploy MiningNiti API with production RAG pipeline
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +7 -0
- Dockerfile +24 -0
- README.md +15 -7
- alembic.ini +112 -0
- alembic/README +1 -0
- alembic/env.py +84 -0
- alembic/script.py.mako +26 -0
- alembic/versions/001_enable_pgvector.py +153 -0
- alembic/versions/002_hybrid_search_index.py +43 -0
- app/README.md +30 -0
- app/__init__.py +7 -0
- app/agents/__init__.py +20 -0
- app/agents/base.py +308 -0
- app/agents/classifier.py +107 -0
- app/agents/compliance_auditor.py +129 -0
- app/agents/entity_extractor.py +130 -0
- app/agents/orchestrator.py +252 -0
- app/agents/safety_analyzer.py +140 -0
- app/agents/summarizer.py +100 -0
- app/api/__init__.py +4 -0
- app/api/deps.py +112 -0
- app/api/v1/__init__.py +8 -0
- app/api/v1/analytics.py +410 -0
- app/api/v1/chat.py +331 -0
- app/api/v1/chat_stream.py +169 -0
- app/api/v1/compliance.py +265 -0
- app/api/v1/documents.py +322 -0
- app/api/v1/health.py +80 -0
- app/api/v1/jobs.py +122 -0
- app/api/v1/prompts.py +200 -0
- app/api/v1/router.py +42 -0
- app/api/v1/search.py +138 -0
- app/api/v1/user.py +105 -0
- app/config.py +125 -0
- app/core/__init__.py +3 -0
- app/core/exceptions.py +115 -0
- app/core/security.py +148 -0
- app/db/__init__.py +7 -0
- app/db/session.py +117 -0
- app/main.py +283 -0
- app/models/__init__.py +30 -0
- app/models/audit.py +142 -0
- app/models/base.py +41 -0
- app/models/chat.py +126 -0
- app/models/compliance.py +156 -0
- app/models/document.py +230 -0
- app/models/prompt.py +63 -0
- app/models/user.py +78 -0
- app/schemas/__init__.py +50 -0
- app/schemas/analytics.py +140 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
venv/
|
| 4 |
+
.env
|
| 5 |
+
*.log
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
test.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
libpq-dev gcc libmagic1 curl \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"
|
| 15 |
+
|
| 16 |
+
ENV TOKENIZERS_PARALLELISM=false
|
| 17 |
+
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
|
| 18 |
+
ENV SENTENCE_TRANSFORMERS_HOME=/app/.cache/sentence-transformers
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
EXPOSE 8000
|
| 23 |
+
|
| 24 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MiningNiti API
|
| 3 |
+
emoji: ⛏️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: true
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# MiningNiti API
|
| 12 |
+
|
| 13 |
+
AI-powered document intelligence engine for the coal mining industry.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- Multi-agent AI pipeline (6 agents, 4 providers)
|
| 17 |
+
- Production RAG with hybrid search + cross-encoder reranking
|
| 18 |
+
- Compliance auto-auditing
|
| 19 |
+
- Real-time streaming chat
|
alembic.ini
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = alembic
|
| 6 |
+
|
| 7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 8 |
+
file_template = %%(rev)s_%%(slug)s
|
| 9 |
+
|
| 10 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 11 |
+
# defaults to the current working directory.
|
| 12 |
+
prepend_sys_path = .
|
| 13 |
+
|
| 14 |
+
# timezone to use when rendering the date within the migration file
|
| 15 |
+
# as well as the filename.
|
| 16 |
+
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
| 17 |
+
# Any required deps can installed via pip[tz]
|
| 18 |
+
# timezone = UTC
|
| 19 |
+
|
| 20 |
+
# max length of characters to apply to the "slug" field
|
| 21 |
+
# truncate_slug_length = 40
|
| 22 |
+
|
| 23 |
+
# set to 'true' to run the environment during
|
| 24 |
+
# the 'revision' command, regardless of autogenerate
|
| 25 |
+
# revision_environment = false
|
| 26 |
+
|
| 27 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 28 |
+
# a source .py file to be detected as revisions in the
|
| 29 |
+
# versions/ directory
|
| 30 |
+
# sourceless = false
|
| 31 |
+
|
| 32 |
+
# version location specification; This defaults
|
| 33 |
+
# to alembic/versions. When using multiple version
|
| 34 |
+
# directories, initial revisions must be specified with --version-path.
|
| 35 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 36 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
| 37 |
+
|
| 38 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 39 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 40 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 41 |
+
# Valid values for version_path_separator are:
|
| 42 |
+
#
|
| 43 |
+
# version_path_separator = :
|
| 44 |
+
# version_path_separator = ;
|
| 45 |
+
# version_path_separator = space
|
| 46 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
| 47 |
+
|
| 48 |
+
# set to 'true' to search source files recursively
|
| 49 |
+
# in each "version_locations" directory
|
| 50 |
+
# New in Alembic version 1.10
|
| 51 |
+
# recursive_version_locations = false
|
| 52 |
+
|
| 53 |
+
# the output encoding used when revision files
|
| 54 |
+
# are written from script.py.mako
|
| 55 |
+
# output_encoding = utf-8
|
| 56 |
+
|
| 57 |
+
# DATABASE_URL is loaded from environment variable in env.py
|
| 58 |
+
# Do NOT hardcode the URL here — use .env file
|
| 59 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
[post_write_hooks]
|
| 63 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 64 |
+
# on newly generated revision scripts. See the documentation for further
|
| 65 |
+
# detail and examples
|
| 66 |
+
|
| 67 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 68 |
+
# hooks = black
|
| 69 |
+
# black.type = console_scripts
|
| 70 |
+
# black.entrypoint = black
|
| 71 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 72 |
+
|
| 73 |
+
# lint with attempts to fix using "ruff" - use the exec runner, against a script
|
| 74 |
+
# hooks = ruff
|
| 75 |
+
# ruff.type = exec
|
| 76 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 77 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
| 78 |
+
|
| 79 |
+
# Logging configuration
|
| 80 |
+
[loggers]
|
| 81 |
+
keys = root,sqlalchemy,alembic
|
| 82 |
+
|
| 83 |
+
[handlers]
|
| 84 |
+
keys = console
|
| 85 |
+
|
| 86 |
+
[formatters]
|
| 87 |
+
keys = generic
|
| 88 |
+
|
| 89 |
+
[logger_root]
|
| 90 |
+
level = WARNING
|
| 91 |
+
handlers = console
|
| 92 |
+
qualname =
|
| 93 |
+
|
| 94 |
+
[logger_sqlalchemy]
|
| 95 |
+
level = WARNING
|
| 96 |
+
handlers =
|
| 97 |
+
qualname = sqlalchemy.engine
|
| 98 |
+
|
| 99 |
+
[logger_alembic]
|
| 100 |
+
level = INFO
|
| 101 |
+
handlers =
|
| 102 |
+
qualname = alembic
|
| 103 |
+
|
| 104 |
+
[handler_console]
|
| 105 |
+
class = StreamHandler
|
| 106 |
+
args = (sys.stderr,)
|
| 107 |
+
level = NOTSET
|
| 108 |
+
formatter = generic
|
| 109 |
+
|
| 110 |
+
[formatter_generic]
|
| 111 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 112 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/env.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alembic Environment Configuration
|
| 3 |
+
Loads DATABASE_URL from .env and connects all SQLAlchemy models.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
from logging.config import fileConfig
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from sqlalchemy import engine_from_config, pool, text
|
| 12 |
+
from alembic import context
|
| 13 |
+
|
| 14 |
+
# ── Ensure 'backend/' is on sys.path so `app.*` imports work ──────────────────
|
| 15 |
+
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
| 16 |
+
sys.path.insert(0, str(BACKEND_DIR))
|
| 17 |
+
|
| 18 |
+
# ── Load settings (reads .env automatically via pydantic-settings) ─────────────
|
| 19 |
+
from app.config import settings # noqa: E402
|
| 20 |
+
|
| 21 |
+
# ── Import ALL models so Alembic can detect their tables ──────────────────────
|
| 22 |
+
from app.models.base import Base # noqa: E402
|
| 23 |
+
from app.models import user, document, chat, audit, prompt # noqa: E402, F401
|
| 24 |
+
|
| 25 |
+
# ── Alembic Config object ─────────────────────────────────────────────────────
|
| 26 |
+
config = context.config
|
| 27 |
+
|
| 28 |
+
# Override sqlalchemy.url with the value from our settings/.env
|
| 29 |
+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
| 30 |
+
|
| 31 |
+
# Interpret the config file for Python logging (if present)
|
| 32 |
+
if config.config_file_name is not None:
|
| 33 |
+
fileConfig(config.config_file_name)
|
| 34 |
+
|
| 35 |
+
# Target metadata for autogenerate support
|
| 36 |
+
target_metadata = Base.metadata
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def run_migrations_offline() -> None:
|
| 40 |
+
"""
|
| 41 |
+
Run migrations in 'offline' mode.
|
| 42 |
+
Generates SQL scripts without a live DB connection.
|
| 43 |
+
"""
|
| 44 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 45 |
+
context.configure(
|
| 46 |
+
url=url,
|
| 47 |
+
target_metadata=target_metadata,
|
| 48 |
+
literal_binds=True,
|
| 49 |
+
dialect_opts={"paramstyle": "named"},
|
| 50 |
+
compare_type=True,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
with context.begin_transaction():
|
| 54 |
+
context.run_migrations()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def run_migrations_online() -> None:
|
| 58 |
+
"""
|
| 59 |
+
Run migrations in 'online' mode.
|
| 60 |
+
Connects to the real database and applies migrations.
|
| 61 |
+
"""
|
| 62 |
+
connectable = engine_from_config(
|
| 63 |
+
config.get_section(config.config_ini_section, {}),
|
| 64 |
+
prefix="sqlalchemy.",
|
| 65 |
+
poolclass=pool.NullPool,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
with connectable.connect() as connection:
|
| 69 |
+
context.configure(
|
| 70 |
+
connection=connection,
|
| 71 |
+
target_metadata=target_metadata,
|
| 72 |
+
compare_type=True,
|
| 73 |
+
# Include schemas for pgvector extension objects
|
| 74 |
+
include_schemas=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
with context.begin_transaction():
|
| 78 |
+
context.run_migrations()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if context.is_offline_mode():
|
| 82 |
+
run_migrations_offline()
|
| 83 |
+
else:
|
| 84 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
alembic/versions/001_enable_pgvector.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Migration: Enable pgvector and migrate embeddings from JSONB to vector(768)
|
| 3 |
+
|
| 4 |
+
Revision ID: 001
|
| 5 |
+
Revises: (initial)
|
| 6 |
+
Create Date: 2026-06-07
|
| 7 |
+
|
| 8 |
+
Changes:
|
| 9 |
+
1. Enable the pgvector extension
|
| 10 |
+
2. Add page tracking columns to document_embeddings
|
| 11 |
+
(section_title, page_numbers)
|
| 12 |
+
3. Migrate embedding column from JSONB to vector(768) native type
|
| 13 |
+
4. Create HNSW index for fast ANN search (~5ms vs seconds)
|
| 14 |
+
5. Fix datetime.utcnow() deprecation in base models (add timezone info)
|
| 15 |
+
6. Add total_pages column to documents
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from alembic import op
|
| 19 |
+
import sqlalchemy as sa
|
| 20 |
+
from sqlalchemy.dialects import postgresql
|
| 21 |
+
|
| 22 |
+
# revision identifiers, used by Alembic
|
| 23 |
+
revision = "001"
|
| 24 |
+
down_revision = None
|
| 25 |
+
branch_labels = None
|
| 26 |
+
depends_on = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def upgrade() -> None:
|
| 30 |
+
# ── Step 1: Enable pgvector extension ──────────────────────────────────────
|
| 31 |
+
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
| 32 |
+
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") # For full-text search
|
| 33 |
+
|
| 34 |
+
# ── Step 2: Add page tracking columns to document_embeddings ───────────────
|
| 35 |
+
op.add_column(
|
| 36 |
+
"document_embeddings",
|
| 37 |
+
sa.Column("section_title", sa.String(500), nullable=True),
|
| 38 |
+
)
|
| 39 |
+
op.add_column(
|
| 40 |
+
"document_embeddings",
|
| 41 |
+
sa.Column(
|
| 42 |
+
"page_numbers",
|
| 43 |
+
postgresql.JSONB(astext_type=sa.Text()),
|
| 44 |
+
nullable=True,
|
| 45 |
+
comment="List of page numbers this chunk spans, e.g. [12, 13]",
|
| 46 |
+
),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# ── Step 3: Add total_pages column to documents ────────────────────────────
|
| 50 |
+
# (page_count already exists — we just ensure it's correctly named)
|
| 51 |
+
# Add total_pages as an alias; keep page_count for backward compatibility
|
| 52 |
+
op.add_column(
|
| 53 |
+
"documents",
|
| 54 |
+
sa.Column("total_pages", sa.Integer(), nullable=True),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# ── Step 4: Migrate embedding column from JSONB → vector(768) ─────────────
|
| 58 |
+
# First add the new column
|
| 59 |
+
op.execute(
|
| 60 |
+
"ALTER TABLE document_embeddings ADD COLUMN embedding_vec vector(768)"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Convert existing JSONB embeddings to vector type
|
| 64 |
+
# This handles both list-of-floats and null values safely
|
| 65 |
+
op.execute(
|
| 66 |
+
"""
|
| 67 |
+
UPDATE document_embeddings
|
| 68 |
+
SET embedding_vec = (
|
| 69 |
+
SELECT array_agg(elem::float8)::vector(768)
|
| 70 |
+
FROM jsonb_array_elements_text(embedding) AS elem
|
| 71 |
+
)
|
| 72 |
+
WHERE embedding IS NOT NULL
|
| 73 |
+
AND jsonb_typeof(embedding) = 'array'
|
| 74 |
+
AND jsonb_array_length(embedding) = 768
|
| 75 |
+
"""
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Drop the old JSONB column and rename new one
|
| 79 |
+
op.execute("ALTER TABLE document_embeddings DROP COLUMN embedding")
|
| 80 |
+
op.execute(
|
| 81 |
+
"ALTER TABLE document_embeddings RENAME COLUMN embedding_vec TO embedding"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Make embedding NOT NULL (existing rows already converted)
|
| 85 |
+
op.execute(
|
| 86 |
+
"ALTER TABLE document_embeddings ALTER COLUMN embedding SET NOT NULL"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# ── Step 5: Create HNSW index for approximate nearest neighbor search ──────
|
| 90 |
+
# HNSW gives sub-5ms search up to ~1M vectors
|
| 91 |
+
# m=16: max connections per node (higher = better recall, more memory)
|
| 92 |
+
# ef_construction=200: build-time search depth (higher = better quality index)
|
| 93 |
+
op.execute(
|
| 94 |
+
"""
|
| 95 |
+
CREATE INDEX idx_embeddings_hnsw
|
| 96 |
+
ON document_embeddings
|
| 97 |
+
USING hnsw (embedding vector_cosine_ops)
|
| 98 |
+
WITH (m = 16, ef_construction = 200)
|
| 99 |
+
"""
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# ── Step 6: Add composite index on (document_id, chunk_index) ──────────────
|
| 103 |
+
op.create_index(
|
| 104 |
+
"idx_embeddings_doc_chunk",
|
| 105 |
+
"document_embeddings",
|
| 106 |
+
["document_id", "chunk_index"],
|
| 107 |
+
unique=True,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# ── Step 7: Add trigram index on documents for full-text search ────────────
|
| 111 |
+
op.execute(
|
| 112 |
+
"""
|
| 113 |
+
CREATE INDEX idx_documents_title_trgm
|
| 114 |
+
ON documents
|
| 115 |
+
USING gin (title gin_trgm_ops)
|
| 116 |
+
"""
|
| 117 |
+
)
|
| 118 |
+
op.execute(
|
| 119 |
+
"""
|
| 120 |
+
CREATE INDEX idx_documents_filename_trgm
|
| 121 |
+
ON documents
|
| 122 |
+
USING gin (file_name gin_trgm_ops)
|
| 123 |
+
"""
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def downgrade() -> None:
|
| 128 |
+
# Remove indexes
|
| 129 |
+
op.execute("DROP INDEX IF EXISTS idx_embeddings_hnsw")
|
| 130 |
+
op.execute("DROP INDEX IF EXISTS idx_documents_title_trgm")
|
| 131 |
+
op.execute("DROP INDEX IF EXISTS idx_documents_filename_trgm")
|
| 132 |
+
op.drop_index("idx_embeddings_doc_chunk", table_name="document_embeddings")
|
| 133 |
+
|
| 134 |
+
# Restore JSONB column
|
| 135 |
+
op.execute(
|
| 136 |
+
"ALTER TABLE document_embeddings ADD COLUMN embedding_jsonb jsonb"
|
| 137 |
+
)
|
| 138 |
+
op.execute(
|
| 139 |
+
"""
|
| 140 |
+
UPDATE document_embeddings
|
| 141 |
+
SET embedding_jsonb = to_jsonb(embedding::float8[])
|
| 142 |
+
WHERE embedding IS NOT NULL
|
| 143 |
+
"""
|
| 144 |
+
)
|
| 145 |
+
op.execute("ALTER TABLE document_embeddings DROP COLUMN embedding")
|
| 146 |
+
op.execute(
|
| 147 |
+
"ALTER TABLE document_embeddings RENAME COLUMN embedding_jsonb TO embedding"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Remove added columns
|
| 151 |
+
op.drop_column("document_embeddings", "section_title")
|
| 152 |
+
op.drop_column("document_embeddings", "page_numbers")
|
| 153 |
+
op.drop_column("documents", "total_pages")
|
alembic/versions/002_hybrid_search_index.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add GIN trigram index on chunk_text for hybrid search
|
| 2 |
+
|
| 3 |
+
Revision ID: 002
|
| 4 |
+
Revises: 001_enable_pgvector
|
| 5 |
+
Create Date: 2025-01-01
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
revision = "002"
|
| 12 |
+
down_revision = "001"
|
| 13 |
+
branch_labels = None
|
| 14 |
+
depends_on = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def upgrade() -> None:
|
| 18 |
+
# Ensure pg_trgm extension exists (for trigram similarity)
|
| 19 |
+
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
| 20 |
+
|
| 21 |
+
# GIN trigram index on chunk_text for fast BM25-style keyword search
|
| 22 |
+
# Enables: WHERE chunk_text % :query (trigram similarity match)
|
| 23 |
+
op.execute(
|
| 24 |
+
"""
|
| 25 |
+
CREATE INDEX IF NOT EXISTS idx_embeddings_chunk_text_trgm
|
| 26 |
+
ON document_embeddings
|
| 27 |
+
USING gin (chunk_text gin_trgm_ops)
|
| 28 |
+
"""
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# GIN trigram index on document content for full-document keyword search
|
| 32 |
+
op.execute(
|
| 33 |
+
"""
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_documents_content_trgm
|
| 35 |
+
ON documents
|
| 36 |
+
USING gin (content gin_trgm_ops)
|
| 37 |
+
"""
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def downgrade() -> None:
|
| 42 |
+
op.execute("DROP INDEX IF EXISTS idx_embeddings_chunk_text_trgm")
|
| 43 |
+
op.execute("DROP INDEX IF EXISTS idx_documents_content_trgm")
|
app/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiningNiti Enterprise Backend
|
| 2 |
+
|
| 3 |
+
Production-ready AI Document Intelligence Engine for the Mining Industry.
|
| 4 |
+
|
| 5 |
+
## Structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
app/
|
| 9 |
+
├── api/ # REST API endpoints
|
| 10 |
+
├── agents/ # AI agents (LangGraph)
|
| 11 |
+
├── core/ # Security, config, exceptions
|
| 12 |
+
├── db/ # Database session & migrations
|
| 13 |
+
├── models/ # SQLAlchemy ORM models
|
| 14 |
+
├── schemas/ # Pydantic request/response schemas
|
| 15 |
+
├── services/ # Business logic layer
|
| 16 |
+
└── workers/ # Celery background tasks
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Quick Start
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
# Install dependencies
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Run development server
|
| 26 |
+
uvicorn app.main:app --reload --port 8000
|
| 27 |
+
|
| 28 |
+
# Run Celery worker
|
| 29 |
+
celery -A app.workers.celery_app worker -l info
|
| 30 |
+
```
|
app/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MiningNiti Enterprise Backend
|
| 3 |
+
AI-Powered Document Intelligence for the Mining Industry
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "2.0.0"
|
| 7 |
+
__author__ = "MiningNiti Team"
|
app/agents/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Agents Module
|
| 3 |
+
Specialized agents for mining document intelligence
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.agents.base import BaseAgent
|
| 7 |
+
from app.agents.classifier import ClassifierAgent
|
| 8 |
+
from app.agents.entity_extractor import EntityExtractorAgent
|
| 9 |
+
from app.agents.orchestrator import AgentOrchestrator
|
| 10 |
+
from app.agents.safety_analyzer import SafetyAnalyzerAgent
|
| 11 |
+
from app.agents.summarizer import SummarizerAgent
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"BaseAgent",
|
| 15 |
+
"ClassifierAgent",
|
| 16 |
+
"SafetyAnalyzerAgent",
|
| 17 |
+
"EntityExtractorAgent",
|
| 18 |
+
"SummarizerAgent",
|
| 19 |
+
"AgentOrchestrator",
|
| 20 |
+
]
|
app/agents/base.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Agent
|
| 3 |
+
Abstract base class for all mining intelligence agents.
|
| 4 |
+
|
| 5 |
+
Improvements over v1:
|
| 6 |
+
- JSON mode via response_mime_type (no more regex JSON extraction)
|
| 7 |
+
- Retry with exponential backoff (3 retries), respecting Gemini retry_delay
|
| 8 |
+
- Processes full document via pages, not truncated to 3000 chars
|
| 9 |
+
- Confidence score required in all agent outputs
|
| 10 |
+
- Proper QuotaExceededError raised (no more silent empty-dict returns)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
import re
|
| 16 |
+
from abc import ABC, abstractmethod
|
| 17 |
+
from typing import Any, Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
import google.generativeai as genai
|
| 20 |
+
from google.generativeai.types import GenerationConfig
|
| 21 |
+
|
| 22 |
+
from app.config import settings
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Configure Gemini once at module level
|
| 27 |
+
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 28 |
+
|
| 29 |
+
# Generation config that forces JSON output — no more regex parsing
|
| 30 |
+
_JSON_GENERATION_CONFIG = GenerationConfig(
|
| 31 |
+
response_mime_type="application/json",
|
| 32 |
+
temperature=0.1, # Low temperature for consistent structured output
|
| 33 |
+
top_p=0.95,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
_MAX_RETRIES = 3
|
| 37 |
+
_RETRY_BASE_DELAY = 2.0 # seconds — minimum delay between retries
|
| 38 |
+
_MAX_RETRY_DELAY = 120.0 # seconds — cap for retry_delay parsed from API response
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class QuotaExceededError(RuntimeError):
|
| 42 |
+
"""Raised when the Gemini API quota / rate-limit is exhausted."""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _parse_retry_delay(err_str: str) -> Optional[float]:
|
| 46 |
+
"""
|
| 47 |
+
Extract the suggested retry_delay (in seconds) from a Gemini 429 error
|
| 48 |
+
message. The error body contains a line like:
|
| 49 |
+
retry_delay { seconds: 31 }
|
| 50 |
+
Returns None if no delay can be parsed.
|
| 51 |
+
"""
|
| 52 |
+
match = re.search(r"retry_delay\s*\{\s*seconds:\s*(\d+)", err_str)
|
| 53 |
+
if match:
|
| 54 |
+
return min(float(match.group(1)), _MAX_RETRY_DELAY)
|
| 55 |
+
# Fallback: look for "Please retry in X.Xs"
|
| 56 |
+
match2 = re.search(r"retry in (\d+\.?\d*)s", err_str)
|
| 57 |
+
if match2:
|
| 58 |
+
return min(float(match2.group(1)), _MAX_RETRY_DELAY)
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class BaseAgent(ABC):
|
| 63 |
+
"""
|
| 64 |
+
Abstract base class for mining document intelligence agents.
|
| 65 |
+
|
| 66 |
+
Each agent is responsible for a specific analysis task:
|
| 67 |
+
- Classification
|
| 68 |
+
- Safety Analysis
|
| 69 |
+
- Entity Extraction
|
| 70 |
+
- Summarization
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
model_name: str = None,
|
| 76 |
+
provider: str = "gemini",
|
| 77 |
+
fallback_model: str = None,
|
| 78 |
+
fallback_provider: str = None,
|
| 79 |
+
):
|
| 80 |
+
self.provider = provider
|
| 81 |
+
self.model_name = model_name or settings.GEMINI_MODEL
|
| 82 |
+
self.name = self.__class__.__name__
|
| 83 |
+
|
| 84 |
+
# Fallback config (e.g. Cerebras when Groq is rate-limited)
|
| 85 |
+
self.fallback_model = fallback_model
|
| 86 |
+
self.fallback_provider = fallback_provider
|
| 87 |
+
self._fallback_client = None
|
| 88 |
+
self._using_fallback = False
|
| 89 |
+
|
| 90 |
+
if self.fallback_provider and self.fallback_model:
|
| 91 |
+
self._init_fallback_client()
|
| 92 |
+
|
| 93 |
+
self._init_client()
|
| 94 |
+
|
| 95 |
+
def _init_client(self):
|
| 96 |
+
"""Initialize the primary provider client."""
|
| 97 |
+
if self.provider == "gemini":
|
| 98 |
+
self.model = genai.GenerativeModel(
|
| 99 |
+
model_name=self.model_name,
|
| 100 |
+
generation_config=_JSON_GENERATION_CONFIG,
|
| 101 |
+
)
|
| 102 |
+
elif self.provider == "groq":
|
| 103 |
+
from app.services.llm_provider import get_groq_client
|
| 104 |
+
|
| 105 |
+
self.client = get_groq_client()
|
| 106 |
+
elif self.provider == "mistral":
|
| 107 |
+
from app.services.llm_provider import get_mistral_client
|
| 108 |
+
|
| 109 |
+
self.client = get_mistral_client()
|
| 110 |
+
elif self.provider == "cerebras":
|
| 111 |
+
from app.services.llm_provider import get_cerebras_client
|
| 112 |
+
|
| 113 |
+
self.client = get_cerebras_client()
|
| 114 |
+
|
| 115 |
+
def _init_fallback_client(self):
|
| 116 |
+
"""Initialize the fallback provider client."""
|
| 117 |
+
if self.fallback_provider == "cerebras":
|
| 118 |
+
from app.services.llm_provider import get_cerebras_client
|
| 119 |
+
|
| 120 |
+
self._fallback_client = get_cerebras_client()
|
| 121 |
+
elif self.fallback_provider == "groq":
|
| 122 |
+
from app.services.llm_provider import get_groq_client
|
| 123 |
+
|
| 124 |
+
self._fallback_client = get_groq_client()
|
| 125 |
+
elif self.fallback_provider == "mistral":
|
| 126 |
+
from app.services.llm_provider import get_mistral_client
|
| 127 |
+
|
| 128 |
+
self._fallback_client = get_mistral_client()
|
| 129 |
+
logger.info(
|
| 130 |
+
f"{self.name}: Fallback configured — {self.fallback_provider}/{self.fallback_model}"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
@abstractmethod
|
| 134 |
+
async def analyze(
|
| 135 |
+
self,
|
| 136 |
+
text: str,
|
| 137 |
+
context: Optional[Dict] = None,
|
| 138 |
+
) -> Dict[str, Any]:
|
| 139 |
+
"""
|
| 140 |
+
Analyze document text and return structured results.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
text: Document text content (full text or representative sample)
|
| 144 |
+
context: Additional context (e.g., document category from classifier)
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Dictionary with agent-specific analysis results.
|
| 148 |
+
All results MUST include a 'confidence' key (0.0–1.0).
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
@property
|
| 152 |
+
@abstractmethod
|
| 153 |
+
def system_prompt(self) -> str:
|
| 154 |
+
"""System prompt defining the agent's role and capabilities."""
|
| 155 |
+
|
| 156 |
+
# ── Generation with retry + fallback ───────────────────────────────────────
|
| 157 |
+
|
| 158 |
+
async def _call_openai_compat(self, client, model: str, prompt: str) -> str:
|
| 159 |
+
"""Call an OpenAI-compatible provider and return text response."""
|
| 160 |
+
response = await client.chat.completions.create(
|
| 161 |
+
model=model,
|
| 162 |
+
messages=[
|
| 163 |
+
{"role": "system", "content": self.system_prompt},
|
| 164 |
+
{"role": "user", "content": prompt},
|
| 165 |
+
],
|
| 166 |
+
response_format={"type": "json_object"},
|
| 167 |
+
temperature=0.1,
|
| 168 |
+
)
|
| 169 |
+
return response.choices[0].message.content or ""
|
| 170 |
+
|
| 171 |
+
async def _generate_json(self, prompt: str) -> Dict[str, Any]:
|
| 172 |
+
"""
|
| 173 |
+
Generate structured JSON output with retry and automatic provider fallback.
|
| 174 |
+
|
| 175 |
+
Primary provider is tried first. On 429/rate-limit errors, if a fallback
|
| 176 |
+
is configured (e.g. Cerebras when Groq is rate-limited), the request
|
| 177 |
+
is retried on the fallback provider before raising QuotaExceededError.
|
| 178 |
+
"""
|
| 179 |
+
import json
|
| 180 |
+
|
| 181 |
+
last_error: Optional[Exception] = None
|
| 182 |
+
for attempt in range(1, _MAX_RETRIES + 1):
|
| 183 |
+
try:
|
| 184 |
+
full_prompt = f"{self.system_prompt}\n\n{prompt}"
|
| 185 |
+
|
| 186 |
+
if self.provider == "gemini":
|
| 187 |
+
response = await asyncio.to_thread(
|
| 188 |
+
self.model.generate_content,
|
| 189 |
+
full_prompt,
|
| 190 |
+
)
|
| 191 |
+
text_response = getattr(response, "text", "")
|
| 192 |
+
elif self.provider in ["groq", "mistral", "cerebras"]:
|
| 193 |
+
text_response = await self._call_openai_compat(
|
| 194 |
+
self.client, self.model_name, prompt
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
try:
|
| 198 |
+
return json.loads(text_response)
|
| 199 |
+
except (json.JSONDecodeError, AttributeError) as parse_err:
|
| 200 |
+
logger.warning(
|
| 201 |
+
f"{self.name} attempt {attempt}: JSON parse failed — {parse_err}. "
|
| 202 |
+
f"Raw response: {text_response[:200]}"
|
| 203 |
+
)
|
| 204 |
+
last_error = parse_err
|
| 205 |
+
delay = _RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
| 206 |
+
await asyncio.sleep(delay)
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
last_error = e
|
| 211 |
+
err_str = str(e)
|
| 212 |
+
|
| 213 |
+
is_quota = (
|
| 214 |
+
"429" in err_str
|
| 215 |
+
or "rate_limit" in err_str.lower()
|
| 216 |
+
or "quota" in err_str.lower()
|
| 217 |
+
or "RESOURCE_EXHAUSTED" in err_str
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
if is_quota:
|
| 221 |
+
# Try fallback provider if available and not already using it
|
| 222 |
+
if (
|
| 223 |
+
self._fallback_client
|
| 224 |
+
and self.fallback_model
|
| 225 |
+
and not self._using_fallback
|
| 226 |
+
):
|
| 227 |
+
logger.warning(
|
| 228 |
+
f"{self.name}: Primary provider rate-limited. "
|
| 229 |
+
f"Falling back to {self.fallback_provider}/{self.fallback_model}"
|
| 230 |
+
)
|
| 231 |
+
try:
|
| 232 |
+
text_response = await self._call_openai_compat(
|
| 233 |
+
self._fallback_client, self.fallback_model, prompt
|
| 234 |
+
)
|
| 235 |
+
result = json.loads(text_response)
|
| 236 |
+
self._using_fallback = True
|
| 237 |
+
return result
|
| 238 |
+
except Exception as fb_err:
|
| 239 |
+
logger.error(f"{self.name}: Fallback also failed: {fb_err}")
|
| 240 |
+
# Fall through to raise QuotaExceededError
|
| 241 |
+
|
| 242 |
+
# Parse the suggested wait time from the error body
|
| 243 |
+
suggested_delay = _parse_retry_delay(err_str)
|
| 244 |
+
|
| 245 |
+
if attempt < _MAX_RETRIES and suggested_delay is not None:
|
| 246 |
+
logger.warning(
|
| 247 |
+
f"{self.name}: Quota/rate-limit hit (attempt {attempt}/{_MAX_RETRIES}). "
|
| 248 |
+
f"Waiting {suggested_delay}s..."
|
| 249 |
+
)
|
| 250 |
+
await asyncio.sleep(suggested_delay)
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
logger.error(f"{self.name}: All providers exhausted — {e}")
|
| 254 |
+
raise QuotaExceededError(
|
| 255 |
+
f"Rate limit exceeded for {self.name}. "
|
| 256 |
+
"Please try again later."
|
| 257 |
+
) from e
|
| 258 |
+
|
| 259 |
+
# Transient non-quota error — exponential backoff
|
| 260 |
+
delay = _RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
| 261 |
+
logger.warning(
|
| 262 |
+
f"{self.name} attempt {attempt}/{_MAX_RETRIES} failed: {e}. "
|
| 263 |
+
f"Retrying in {delay}s..."
|
| 264 |
+
)
|
| 265 |
+
await asyncio.sleep(delay)
|
| 266 |
+
|
| 267 |
+
logger.error(f"{self.name} failed after {_MAX_RETRIES} attempts: {last_error}")
|
| 268 |
+
return {}
|
| 269 |
+
|
| 270 |
+
# ── Text helpers ───────────────────────────────────────────────────────────
|
| 271 |
+
|
| 272 |
+
def _prepare_text(self, text: str, max_chars: int = 15000) -> str:
|
| 273 |
+
"""
|
| 274 |
+
Prepare text for agent analysis.
|
| 275 |
+
|
| 276 |
+
Instead of hard-truncating to 3000 chars (old behavior), we use up to
|
| 277 |
+
15000 chars (≈10 pages) to capture much more document content.
|
| 278 |
+
Long documents get the first 12000 chars + last 3000 chars to include
|
| 279 |
+
both the opening context and the conclusion/summary sections.
|
| 280 |
+
"""
|
| 281 |
+
if len(text) <= max_chars:
|
| 282 |
+
return text
|
| 283 |
+
|
| 284 |
+
head = text[:12000]
|
| 285 |
+
tail = text[-3000:]
|
| 286 |
+
return head + "\n\n[... middle of document omitted for analysis ...]\n\n" + tail
|
| 287 |
+
|
| 288 |
+
# ── Kept for backward compatibility ───────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
def _parse_json(self, text: str) -> Dict[str, Any]:
|
| 291 |
+
"""Legacy JSON parser — kept for any subclass that still needs it."""
|
| 292 |
+
import json
|
| 293 |
+
import re
|
| 294 |
+
|
| 295 |
+
text = text.strip()
|
| 296 |
+
if text.startswith("```"):
|
| 297 |
+
lines = text.split("\n")
|
| 298 |
+
text = "\n".join(line for line in lines if not line.startswith("```"))
|
| 299 |
+
try:
|
| 300 |
+
return json.loads(text)
|
| 301 |
+
except json.JSONDecodeError:
|
| 302 |
+
match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 303 |
+
if match:
|
| 304 |
+
try:
|
| 305 |
+
return json.loads(match.group())
|
| 306 |
+
except json.JSONDecodeError:
|
| 307 |
+
pass
|
| 308 |
+
return {}
|
app/agents/classifier.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Classifier Agent
|
| 3 |
+
Document classification for mining industry categories
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Any, Dict, Optional
|
| 9 |
+
|
| 10 |
+
from app.agents.base import BaseAgent
|
| 11 |
+
from app.models.document import DocumentCategory
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ClassifierAgent(BaseAgent):
|
| 17 |
+
"""
|
| 18 |
+
Document Classification Agent.
|
| 19 |
+
|
| 20 |
+
Categorizes mining documents into predefined categories:
|
| 21 |
+
- Safety protocols
|
| 22 |
+
- Equipment manuals
|
| 23 |
+
- Regulatory documents
|
| 24 |
+
- Incident reports
|
| 25 |
+
- Geological reports
|
| 26 |
+
- Environmental reports
|
| 27 |
+
- Training materials
|
| 28 |
+
- Permits
|
| 29 |
+
- Maintenance logs
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self):
|
| 33 |
+
super().__init__(model_name="llama-3.3-70b-versatile", provider="groq")
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def system_prompt(self) -> str:
|
| 37 |
+
return """You are a document classification agent specialized in the mining industry.
|
| 38 |
+
|
| 39 |
+
Your task is to analyze documents and classify them into the appropriate category based on their content, structure, and purpose.
|
| 40 |
+
|
| 41 |
+
Categories:
|
| 42 |
+
1. safety_protocol - Safety procedures, guidelines, emergency protocols
|
| 43 |
+
2. equipment_manual - Equipment operation guides, maintenance manuals
|
| 44 |
+
3. regulatory - MSHA, OSHA, EPA regulations, compliance documents
|
| 45 |
+
4. incident_report - Accident reports, incident investigations, near-miss reports
|
| 46 |
+
5. geological - Drill logs, assay reports, geological surveys, core samples
|
| 47 |
+
6. environmental - Environmental impact assessments, monitoring reports
|
| 48 |
+
7. training - Training materials, certifications, competency assessments
|
| 49 |
+
8. permit - Mining permits, licenses, applications
|
| 50 |
+
9. maintenance - Maintenance schedules, repair logs, equipment inspections
|
| 51 |
+
10. other - Documents that don't fit other categories
|
| 52 |
+
|
| 53 |
+
Consider:
|
| 54 |
+
- Document structure and formatting
|
| 55 |
+
- Key terminology and language used
|
| 56 |
+
- Purpose and intended audience
|
| 57 |
+
- Regulatory references
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
async def analyze(
|
| 61 |
+
self, text: str, context: Optional[Dict] = None
|
| 62 |
+
) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Classify document into mining category.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
{
|
| 68 |
+
"category": str (DocumentCategory value),
|
| 69 |
+
"subcategory": str,
|
| 70 |
+
"confidence": float (0-1),
|
| 71 |
+
"reasoning": str
|
| 72 |
+
}
|
| 73 |
+
"""
|
| 74 |
+
prompt = f"""Analyze this mining document and classify it.
|
| 75 |
+
|
| 76 |
+
Document content ({len(text)} chars total, showing up to 15000):
|
| 77 |
+
{self._prepare_text(text)}
|
| 78 |
+
|
| 79 |
+
Respond with a JSON object:
|
| 80 |
+
{{
|
| 81 |
+
"category": "<one of: safety_protocol|equipment_manual|regulatory|incident_report|geological|environmental|training|permit|maintenance|other>",
|
| 82 |
+
"subcategory": "<more specific type if applicable, else null>",
|
| 83 |
+
"confidence": <0.0-1.0>,
|
| 84 |
+
"reasoning": "<brief explanation of classification decision>"
|
| 85 |
+
}}
|
| 86 |
+
"""
|
| 87 |
+
result = await self._generate_json(prompt)
|
| 88 |
+
|
| 89 |
+
category_str = (result.get("category") or "other").lower().strip()
|
| 90 |
+
category_map = {
|
| 91 |
+
"safety_protocol": DocumentCategory.SAFETY_PROTOCOL,
|
| 92 |
+
"equipment_manual": DocumentCategory.EQUIPMENT_MANUAL,
|
| 93 |
+
"regulatory": DocumentCategory.REGULATORY,
|
| 94 |
+
"incident_report": DocumentCategory.INCIDENT_REPORT,
|
| 95 |
+
"geological": DocumentCategory.GEOLOGICAL,
|
| 96 |
+
"environmental": DocumentCategory.ENVIRONMENTAL,
|
| 97 |
+
"training": DocumentCategory.TRAINING,
|
| 98 |
+
"permit": DocumentCategory.PERMIT,
|
| 99 |
+
"maintenance": DocumentCategory.MAINTENANCE,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
"category": category_map.get(category_str, DocumentCategory.OTHER).value,
|
| 104 |
+
"subcategory": result.get("subcategory"),
|
| 105 |
+
"confidence": float(result.get("confidence") or 0.5),
|
| 106 |
+
"reasoning": result.get("reasoning", ""),
|
| 107 |
+
}
|
app/agents/compliance_auditor.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compliance Auditor Agent
|
| 3 |
+
Cross-references a regulation clause against operational document evidence
|
| 4 |
+
to determine compliance status (compliant / gap / missing).
|
| 5 |
+
|
| 6 |
+
Uses Gemini for the nuanced cross-referencing task that requires reasoning
|
| 7 |
+
across multiple evidence chunks.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from app.agents.base import BaseAgent
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ComplianceAuditorAgent(BaseAgent):
|
| 19 |
+
"""
|
| 20 |
+
Regulatory Compliance Auditor Agent.
|
| 21 |
+
|
| 22 |
+
Takes a single regulation clause and a set of evidence chunks from
|
| 23 |
+
operational documents, then assesses whether the operational documents
|
| 24 |
+
adequately address the clause requirements.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
super().__init__(model_name="llama-3.3-70b-versatile", provider="groq")
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def system_prompt(self) -> str:
|
| 32 |
+
return """You are a regulatory compliance auditor specializing in the mining industry.
|
| 33 |
+
|
| 34 |
+
Your expertise includes:
|
| 35 |
+
- MSHA (Mine Safety and Health Administration) regulations (30 CFR)
|
| 36 |
+
- OSHA safety standards (29 CFR 1910, 1926)
|
| 37 |
+
- DGMS (Directorate General of Mines Safety) regulations
|
| 38 |
+
- EPA environmental regulations for mining operations
|
| 39 |
+
- State-level mining regulations and permits
|
| 40 |
+
|
| 41 |
+
Your task: Given a REGULATION CLAUSE and EVIDENCE CHUNKS from operational documents,
|
| 42 |
+
assess whether the operational documents adequately address the clause requirements.
|
| 43 |
+
|
| 44 |
+
Assessment statuses:
|
| 45 |
+
- "compliant": The operational documents clearly address the regulation clause requirements
|
| 46 |
+
- "gap": The operational documents partially address the clause but have gaps or deficiencies
|
| 47 |
+
- "missing": The operational documents do not address this clause at all, or the evidence is insufficient
|
| 48 |
+
|
| 49 |
+
Be strict but fair. If the evidence is thin but directionally correct, mark as "gap" not "missing".
|
| 50 |
+
Only mark "compliant" if the evidence clearly and adequately addresses the clause.
|
| 51 |
+
|
| 52 |
+
Always cite specific evidence in your assessment. If no evidence is provided, mark as "missing".
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
async def analyze(
|
| 56 |
+
self,
|
| 57 |
+
text: str,
|
| 58 |
+
context: Optional[Dict] = None,
|
| 59 |
+
) -> Dict[str, Any]:
|
| 60 |
+
"""
|
| 61 |
+
Assess compliance for a single regulation clause.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
text: The regulation clause text
|
| 65 |
+
context: Must contain:
|
| 66 |
+
- evidence_chunks: List of dicts with chunk_text, document_title,
|
| 67 |
+
page_numbers, relevance_score
|
| 68 |
+
- clause_section: Section title from the regulation document
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
{
|
| 72 |
+
"status": "compliant" | "gap" | "missing",
|
| 73 |
+
"assessment": str,
|
| 74 |
+
"confidence": float (0.0-1.0),
|
| 75 |
+
"recommendations": list[str],
|
| 76 |
+
}
|
| 77 |
+
"""
|
| 78 |
+
evidence_chunks: List[Dict] = (
|
| 79 |
+
context.get("evidence_chunks", []) if context else []
|
| 80 |
+
)
|
| 81 |
+
clause_section = context.get("clause_section", "") if context else ""
|
| 82 |
+
|
| 83 |
+
# Format evidence for the prompt
|
| 84 |
+
if evidence_chunks:
|
| 85 |
+
evidence_text = "\n\n".join(
|
| 86 |
+
f"[Evidence {i+1}] From '{chunk.get('document_title', 'Unknown')}'"
|
| 87 |
+
f" (Pages {chunk.get('page_numbers', ['?'])}, "
|
| 88 |
+
f"Relevance: {chunk.get('relevance_score', 0):.0%}):\n"
|
| 89 |
+
f"{chunk.get('chunk_text', '')}"
|
| 90 |
+
for i, chunk in enumerate(evidence_chunks)
|
| 91 |
+
)
|
| 92 |
+
else:
|
| 93 |
+
evidence_text = "No relevant evidence found in operational documents."
|
| 94 |
+
|
| 95 |
+
section_hint = (
|
| 96 |
+
f"\nRegulation Section: {clause_section}" if clause_section else ""
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
prompt = (
|
| 100 |
+
"Assess compliance for the following regulation clause against "
|
| 101 |
+
"the provided operational document evidence.\n\n"
|
| 102 |
+
f"REGULATION CLAUSE{text}:\n{text}\n\n"
|
| 103 |
+
f"{section_hint}\n\n"
|
| 104 |
+
f"EVIDENCE FROM OPERATIONAL DOCUMENTS:\n{evidence_text}\n\n"
|
| 105 |
+
"Respond with a JSON object:\n"
|
| 106 |
+
"{\n"
|
| 107 |
+
' "status": "<compliant|gap|missing>",\n'
|
| 108 |
+
' "assessment": "<2-4 sentence explanation of compliance status, '
|
| 109 |
+
'citing specific evidence where available>",\n'
|
| 110 |
+
' "confidence": <0.0-1.0>,\n'
|
| 111 |
+
' "recommendations": ["<specific actionable recommendation to close gaps>"]\n'
|
| 112 |
+
"}\n"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
result = await self._generate_json(prompt)
|
| 116 |
+
|
| 117 |
+
status = (result.get("status") or "missing").lower()
|
| 118 |
+
if status not in ("compliant", "gap", "missing"):
|
| 119 |
+
status = "missing"
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"status": status,
|
| 123 |
+
"assessment": result.get(
|
| 124 |
+
"assessment",
|
| 125 |
+
"Assessment could not be generated.",
|
| 126 |
+
),
|
| 127 |
+
"confidence": float(result.get("confidence") or 0.5),
|
| 128 |
+
"recommendations": result.get("recommendations", []),
|
| 129 |
+
}
|
app/agents/entity_extractor.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Entity Extractor Agent
|
| 3 |
+
Mining-specific Named Entity Recognition
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from app.agents.base import BaseAgent
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EntityExtractorAgent(BaseAgent):
|
| 15 |
+
"""
|
| 16 |
+
Named Entity Recognition Agent for Mining Documents.
|
| 17 |
+
|
| 18 |
+
Extracts mining-specific entities:
|
| 19 |
+
- Equipment names and models
|
| 20 |
+
- Chemical compounds and gases
|
| 21 |
+
- Mine locations and sections
|
| 22 |
+
- Personnel and roles
|
| 23 |
+
- Dates and schedules
|
| 24 |
+
- Regulatory references
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
# Cerebras: 1M tokens/day free, 2600+ TPS, 60K TPM — better for high-volume extraction than Groq
|
| 29 |
+
super().__init__(model_name="gpt-oss-120b", provider="cerebras")
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def system_prompt(self) -> str:
|
| 33 |
+
return """You are a named entity extraction agent specialized in mining documents.
|
| 34 |
+
|
| 35 |
+
Extract the following entity types:
|
| 36 |
+
|
| 37 |
+
1. EQUIPMENT
|
| 38 |
+
- Mining machinery (excavators, haul trucks, drills)
|
| 39 |
+
- Brand names and models (Caterpillar D11, Komatsu PC8000)
|
| 40 |
+
- Equipment IDs and serial numbers
|
| 41 |
+
- Tools and instruments
|
| 42 |
+
|
| 43 |
+
2. CHEMICALS
|
| 44 |
+
- Gases (methane, CO, H2S, oxygen)
|
| 45 |
+
- Minerals and ores
|
| 46 |
+
- Explosives and blasting agents
|
| 47 |
+
- Dust types (coal dust, silica)
|
| 48 |
+
- Hazardous substances
|
| 49 |
+
|
| 50 |
+
3. LOCATIONS
|
| 51 |
+
- Mine names
|
| 52 |
+
- Sections and portals
|
| 53 |
+
- Underground levels
|
| 54 |
+
- Surface areas
|
| 55 |
+
- Geographic coordinates
|
| 56 |
+
|
| 57 |
+
4. PERSONNEL
|
| 58 |
+
- Names (anonymize if needed)
|
| 59 |
+
- Roles (Safety Officer, Foreman, Engineer)
|
| 60 |
+
- Departments and teams
|
| 61 |
+
- Certifications
|
| 62 |
+
|
| 63 |
+
5. DATES
|
| 64 |
+
- Specific dates
|
| 65 |
+
- Deadlines
|
| 66 |
+
- Scheduled events
|
| 67 |
+
- Time periods
|
| 68 |
+
|
| 69 |
+
6. REGULATIONS
|
| 70 |
+
- MSHA regulations (30 CFR citations)
|
| 71 |
+
- OSHA standards
|
| 72 |
+
- EPA requirements
|
| 73 |
+
- State regulations
|
| 74 |
+
- Company policies
|
| 75 |
+
|
| 76 |
+
Be precise and avoid duplicates. Extract exactly as written in the document.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
async def analyze(
|
| 80 |
+
self, text: str, context: Optional[Dict] = None
|
| 81 |
+
) -> Dict[str, Any]:
|
| 82 |
+
"""
|
| 83 |
+
Extract named entities from document.
|
| 84 |
+
|
| 85 |
+
Returns dict with keys: equipment, chemicals, locations, personnel,
|
| 86 |
+
dates, regulations (all List[str]), entity_count (int)
|
| 87 |
+
"""
|
| 88 |
+
prompt = f"""Extract all mining-specific named entities from this document.
|
| 89 |
+
|
| 90 |
+
Document content ({len(text)} chars total, showing up to 15000):
|
| 91 |
+
{self._prepare_text(text)}
|
| 92 |
+
|
| 93 |
+
Respond with a JSON object:
|
| 94 |
+
{{
|
| 95 |
+
"equipment": ["<equipment name or model>"],
|
| 96 |
+
"chemicals": ["<chemical compound, gas, or mineral>"],
|
| 97 |
+
"locations": ["<mine name, section, or area>"],
|
| 98 |
+
"personnel": ["<role or name>"],
|
| 99 |
+
"dates": ["<date or time period>"],
|
| 100 |
+
"regulations": ["<e.g. 30 CFR 75.400, OSHA 1910.134>"]
|
| 101 |
+
}}
|
| 102 |
+
|
| 103 |
+
Notes:
|
| 104 |
+
- List each unique entity only once
|
| 105 |
+
- Use exact text from document
|
| 106 |
+
- For personnel, prefer roles over names for privacy
|
| 107 |
+
- Include regulation citations in standard format
|
| 108 |
+
"""
|
| 109 |
+
result = await self._generate_json(prompt)
|
| 110 |
+
|
| 111 |
+
entities = {
|
| 112 |
+
"equipment": self._deduplicate(result.get("equipment", [])),
|
| 113 |
+
"chemicals": self._deduplicate(result.get("chemicals", [])),
|
| 114 |
+
"locations": self._deduplicate(result.get("locations", [])),
|
| 115 |
+
"personnel": self._deduplicate(result.get("personnel", [])),
|
| 116 |
+
"dates": self._deduplicate(result.get("dates", [])),
|
| 117 |
+
"regulations": self._deduplicate(result.get("regulations", [])),
|
| 118 |
+
}
|
| 119 |
+
entities["entity_count"] = sum(len(v) for v in entities.values())
|
| 120 |
+
return entities
|
| 121 |
+
|
| 122 |
+
def _deduplicate(self, items: List[str]) -> List[str]:
|
| 123 |
+
"""Remove duplicates while preserving order"""
|
| 124 |
+
seen = set()
|
| 125 |
+
result = []
|
| 126 |
+
for item in items:
|
| 127 |
+
if item and item.lower() not in seen:
|
| 128 |
+
seen.add(item.lower())
|
| 129 |
+
result.append(item)
|
| 130 |
+
return result
|
app/agents/orchestrator.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Orchestrator
|
| 3 |
+
Coordinates multi-agent document analysis pipeline.
|
| 4 |
+
|
| 5 |
+
Improvements over v1:
|
| 6 |
+
- Accepts pages parameter from extractor for future per-page analysis
|
| 7 |
+
- Runs safety/entity/summary with small delay between calls to avoid simultaneous quota hits
|
| 8 |
+
- Adds per-agent timing metrics
|
| 9 |
+
- All agents use JSON mode (no regex) + exponential backoff retry
|
| 10 |
+
- Surfaces QuotaExceededError instead of silently returning empty data
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
+
|
| 18 |
+
from app.agents.base import QuotaExceededError
|
| 19 |
+
from app.agents.classifier import ClassifierAgent
|
| 20 |
+
from app.agents.entity_extractor import EntityExtractorAgent
|
| 21 |
+
from app.agents.safety_analyzer import SafetyAnalyzerAgent
|
| 22 |
+
from app.agents.summarizer import SummarizerAgent
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
import functools
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def exponential_backoff_wrapper(max_retries: int = 3, base_delay: float = 2.0):
|
| 31 |
+
"""Exponential Backoff Utility Wrapper for AI Agent execution pipelines."""
|
| 32 |
+
|
| 33 |
+
def decorator(func):
|
| 34 |
+
@functools.wraps(func)
|
| 35 |
+
async def wrapper(*args, **kwargs):
|
| 36 |
+
last_error = None
|
| 37 |
+
for attempt in range(1, max_retries + 1):
|
| 38 |
+
try:
|
| 39 |
+
return await func(*args, **kwargs)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
err_str = str(e).lower()
|
| 42 |
+
is_rate_limit = any(
|
| 43 |
+
term in err_str
|
| 44 |
+
for term in [
|
| 45 |
+
"429",
|
| 46 |
+
"quota",
|
| 47 |
+
"timeout",
|
| 48 |
+
"rate_limit",
|
| 49 |
+
"resource_exhausted",
|
| 50 |
+
"too many requests",
|
| 51 |
+
]
|
| 52 |
+
)
|
| 53 |
+
if is_rate_limit:
|
| 54 |
+
last_error = e
|
| 55 |
+
if attempt < max_retries:
|
| 56 |
+
delay = base_delay * (2 ** (attempt - 1))
|
| 57 |
+
logger.warning(
|
| 58 |
+
f"Audit Trail: {func.__name__} attempt {attempt} failed (429/Timeout). Retrying in {delay}s..."
|
| 59 |
+
)
|
| 60 |
+
await asyncio.sleep(delay)
|
| 61 |
+
else:
|
| 62 |
+
logger.error(
|
| 63 |
+
f"Audit Trail: {func.__name__} exhausted {max_retries} retries."
|
| 64 |
+
)
|
| 65 |
+
raise QuotaExceededError(
|
| 66 |
+
f"Rate limit/timeout exceeded in {func.__name__}"
|
| 67 |
+
) from e
|
| 68 |
+
else:
|
| 69 |
+
raise
|
| 70 |
+
if last_error is not None:
|
| 71 |
+
raise last_error
|
| 72 |
+
raise RuntimeError(f"Task {func.__name__} failed with no attempts made")
|
| 73 |
+
|
| 74 |
+
return wrapper
|
| 75 |
+
|
| 76 |
+
return decorator
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class AgentOrchestrator:
|
| 80 |
+
"""
|
| 81 |
+
Multi-Agent Orchestrator for Document Intelligence.
|
| 82 |
+
|
| 83 |
+
Execution order:
|
| 84 |
+
1. ClassifierAgent — runs first (result feeds category context to others)
|
| 85 |
+
2. SafetyAnalyzerAgent ┐
|
| 86 |
+
3. EntityExtractorAgent ├── run in parallel after classification (Multi-Provider)
|
| 87 |
+
4. SummarizerAgent ┘
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def __init__(self):
|
| 91 |
+
self.classifier = ClassifierAgent()
|
| 92 |
+
self.safety_analyzer = SafetyAnalyzerAgent()
|
| 93 |
+
self.entity_extractor = EntityExtractorAgent()
|
| 94 |
+
self.summarizer = SummarizerAgent()
|
| 95 |
+
|
| 96 |
+
async def analyze_document(
|
| 97 |
+
self,
|
| 98 |
+
text: str,
|
| 99 |
+
pages: Optional[List] = None,
|
| 100 |
+
) -> Dict[str, Any]:
|
| 101 |
+
"""
|
| 102 |
+
Run full multi-agent analysis pipeline on document.
|
| 103 |
+
"""
|
| 104 |
+
start_time = datetime.now(timezone.utc)
|
| 105 |
+
logger.info("Starting multi-agent document analysis")
|
| 106 |
+
|
| 107 |
+
agent_timings: Dict[str, int] = {}
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
# ── Step 1: Classification (feeds category context to other agents) ──
|
| 111 |
+
t0 = datetime.now(timezone.utc)
|
| 112 |
+
logger.info("Running ClassifierAgent...")
|
| 113 |
+
|
| 114 |
+
@exponential_backoff_wrapper()
|
| 115 |
+
async def _run_classifier():
|
| 116 |
+
return await self.classifier.analyze(text)
|
| 117 |
+
|
| 118 |
+
classification = await _run_classifier()
|
| 119 |
+
agent_timings["classifier_ms"] = int(
|
| 120 |
+
(datetime.now(timezone.utc) - t0).total_seconds() * 1000
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
category = classification.get("category", "other")
|
| 124 |
+
context = {"category": category}
|
| 125 |
+
|
| 126 |
+
# ── Step 2: Parallel agents using category context ─────────────────
|
| 127 |
+
logger.info(
|
| 128 |
+
"Running parallel agents across multiple providers (Safety, Entity, Summary)..."
|
| 129 |
+
)
|
| 130 |
+
t1 = datetime.now(timezone.utc)
|
| 131 |
+
|
| 132 |
+
@exponential_backoff_wrapper()
|
| 133 |
+
async def _run_safety():
|
| 134 |
+
# Only run safety analysis on relevant document categories
|
| 135 |
+
non_safety_categories = [
|
| 136 |
+
"regulatory",
|
| 137 |
+
"geological",
|
| 138 |
+
"environmental",
|
| 139 |
+
"permit",
|
| 140 |
+
"other",
|
| 141 |
+
]
|
| 142 |
+
if category in non_safety_categories:
|
| 143 |
+
logger.info(
|
| 144 |
+
f"Routing: Document is {category}. Bypassing Safety Analyzer."
|
| 145 |
+
)
|
| 146 |
+
return {
|
| 147 |
+
"status": "not_applicable",
|
| 148 |
+
"score": None,
|
| 149 |
+
"hazards": [],
|
| 150 |
+
"recommendations": [
|
| 151 |
+
f"Safety analysis bypassed for {category} document"
|
| 152 |
+
],
|
| 153 |
+
}
|
| 154 |
+
return await self.safety_analyzer.analyze(text, context)
|
| 155 |
+
|
| 156 |
+
@exponential_backoff_wrapper()
|
| 157 |
+
async def _run_entities():
|
| 158 |
+
return await self.entity_extractor.analyze(text, context)
|
| 159 |
+
|
| 160 |
+
@exponential_backoff_wrapper()
|
| 161 |
+
async def _run_summary():
|
| 162 |
+
return await self.summarizer.analyze(text, context)
|
| 163 |
+
|
| 164 |
+
safety, entities, summary = await asyncio.gather(
|
| 165 |
+
_run_safety(),
|
| 166 |
+
_run_entities(),
|
| 167 |
+
_run_summary(),
|
| 168 |
+
return_exceptions=True,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
agent_timings["parallel_agents_ms"] = int(
|
| 172 |
+
(datetime.now(timezone.utc) - t1).total_seconds() * 1000
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Handle per-agent exceptions gracefully
|
| 176 |
+
def _quota_error_result(agent_name: str, exc: Exception) -> dict:
|
| 177 |
+
is_quota = isinstance(exc, QuotaExceededError)
|
| 178 |
+
logger.error(f"{agent_name} failed: {exc}")
|
| 179 |
+
return {
|
| 180 |
+
"error": str(exc),
|
| 181 |
+
"quota_exceeded": is_quota,
|
| 182 |
+
"status": "quota_exceeded" if is_quota else "error",
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if isinstance(safety, Exception):
|
| 186 |
+
safety = {
|
| 187 |
+
**_quota_error_result("SafetyAnalyzerAgent", safety),
|
| 188 |
+
"score": None,
|
| 189 |
+
"hazards": [],
|
| 190 |
+
"recommendations": [],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if isinstance(entities, Exception):
|
| 194 |
+
entities = {
|
| 195 |
+
**_quota_error_result("EntityExtractorAgent", entities),
|
| 196 |
+
"equipment": [],
|
| 197 |
+
"chemicals": [],
|
| 198 |
+
"locations": [],
|
| 199 |
+
"personnel": [],
|
| 200 |
+
"dates": [],
|
| 201 |
+
"regulations": [],
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if isinstance(summary, Exception):
|
| 205 |
+
summary = {
|
| 206 |
+
**_quota_error_result("SummarizerAgent", summary),
|
| 207 |
+
"summary": "Analysis failed — Gemini API quota exceeded. Please try again later.",
|
| 208 |
+
"key_points": [],
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
total_ms = int(
|
| 212 |
+
(datetime.now(timezone.utc) - start_time).total_seconds() * 1000
|
| 213 |
+
)
|
| 214 |
+
logger.info(f"Multi-agent analysis completed in {total_ms}ms")
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"classification": classification,
|
| 218 |
+
"safety": safety,
|
| 219 |
+
"entities": entities,
|
| 220 |
+
"summary": summary,
|
| 221 |
+
"metadata": {
|
| 222 |
+
"processing_time_ms": total_ms,
|
| 223 |
+
"agent_timings": agent_timings,
|
| 224 |
+
"agents_used": [
|
| 225 |
+
"classifier",
|
| 226 |
+
"safety_analyzer",
|
| 227 |
+
"entity_extractor",
|
| 228 |
+
"summarizer",
|
| 229 |
+
],
|
| 230 |
+
"analyzed_at": datetime.now(timezone.utc).isoformat(),
|
| 231 |
+
},
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
except QuotaExceededError:
|
| 235 |
+
# Re-raise quota errors so document_service.py can handle them
|
| 236 |
+
# with its dedicated QuotaExceededError handler (partial save, not FAILED).
|
| 237 |
+
raise
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Orchestrator failed: {e}", exc_info=True)
|
| 241 |
+
return {
|
| 242 |
+
"error": str(e),
|
| 243 |
+
"metadata": {"failed": True, "error_message": str(e)},
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
async def analyze_for_safety_only(self, text: str) -> Dict[str, Any]:
|
| 247 |
+
"""Quick safety-only analysis for real-time checks."""
|
| 248 |
+
return await self.safety_analyzer.analyze(text)
|
| 249 |
+
|
| 250 |
+
async def classify_only(self, text: str) -> Dict[str, Any]:
|
| 251 |
+
"""Quick classification only."""
|
| 252 |
+
return await self.classifier.analyze(text)
|
app/agents/safety_analyzer.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Safety Analyzer Agent
|
| 3 |
+
Mining safety compliance and hazard detection.
|
| 4 |
+
|
| 5 |
+
Uses JSON mode (response_mime_type=application/json) + retry for reliable output.
|
| 6 |
+
Processes up to 15000 chars of document content (vs 5000 in v1).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from app.agents.base import BaseAgent
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SafetyAnalyzerAgent(BaseAgent):
|
| 18 |
+
"""
|
| 19 |
+
Safety Compliance Analysis Agent.
|
| 20 |
+
|
| 21 |
+
Analyzes mining documents for:
|
| 22 |
+
- MSHA/OSHA/DGMS compliance issues
|
| 23 |
+
- Safety hazards and risks
|
| 24 |
+
- Missing safety requirements
|
| 25 |
+
- Recommendations for improvement
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
# magistral-small-latest is a reasoning model, not a standard chat model.
|
| 30 |
+
# It uses chain-of-thought internally for compliance scoring.
|
| 31 |
+
super().__init__(model_name="magistral-small-latest", provider="mistral")
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def system_prompt(self) -> str:
|
| 35 |
+
return """You are a mining safety compliance analysis agent.
|
| 36 |
+
|
| 37 |
+
Your expertise includes:
|
| 38 |
+
- MSHA (Mine Safety and Health Administration) regulations
|
| 39 |
+
- OSHA safety standards
|
| 40 |
+
- DGMS (Directorate General of Mines Safety) regulations
|
| 41 |
+
- Underground and surface mining safety
|
| 42 |
+
- Equipment safety requirements
|
| 43 |
+
- Emergency response protocols
|
| 44 |
+
- Ventilation and air quality standards
|
| 45 |
+
- Ground control and stability
|
| 46 |
+
- Electrical safety in mining
|
| 47 |
+
- Personal protective equipment (PPE)
|
| 48 |
+
- Hazardous materials handling
|
| 49 |
+
|
| 50 |
+
Analyze documents for:
|
| 51 |
+
1. Compliance with regulations
|
| 52 |
+
2. Potential safety hazards
|
| 53 |
+
3. Missing safety procedures
|
| 54 |
+
4. Risk factors
|
| 55 |
+
5. Areas needing improvement
|
| 56 |
+
|
| 57 |
+
Be thorough but practical. Focus on actionable findings.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
async def analyze(
|
| 61 |
+
self, text: str, context: Optional[Dict] = None
|
| 62 |
+
) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Analyze document for safety compliance and hazards.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
{
|
| 68 |
+
"score": float (0-100),
|
| 69 |
+
"status": str ("compliant"|"warning"|"violation"),
|
| 70 |
+
"hazards": List[Dict],
|
| 71 |
+
"recommendations": List[str],
|
| 72 |
+
"compliance_details": Dict,
|
| 73 |
+
"confidence": float (0-1),
|
| 74 |
+
"reasoning": Dict (explainability layer)
|
| 75 |
+
}
|
| 76 |
+
"""
|
| 77 |
+
category = context.get("category", "unknown") if context else "unknown"
|
| 78 |
+
|
| 79 |
+
prompt = (
|
| 80 |
+
"Analyze this mining document for safety compliance and hazards.\n\n"
|
| 81 |
+
f"Document Type: {category}\n"
|
| 82 |
+
f"Document content ({len(text)} chars total, showing up to 15000):\n"
|
| 83 |
+
f"{self._prepare_text(text)}\n\n"
|
| 84 |
+
"Evaluate and respond with a JSON object:\n"
|
| 85 |
+
"{\n"
|
| 86 |
+
' "score": <0-100 overall safety score>,\n'
|
| 87 |
+
' "status": "<compliant|warning|violation>",\n'
|
| 88 |
+
' "confidence": <0.0-1.0>,\n'
|
| 89 |
+
' "hazards": [\n'
|
| 90 |
+
" {\n"
|
| 91 |
+
' "type": "<hazard category>",\n'
|
| 92 |
+
' "severity": "<low|medium|high|critical>",\n'
|
| 93 |
+
' "description": "<specific hazard details>",\n'
|
| 94 |
+
' "regulation": "<relevant MSHA/OSHA/DGMS regulation if applicable>"\n'
|
| 95 |
+
" }\n"
|
| 96 |
+
" ],\n"
|
| 97 |
+
' "recommendations": ["<specific actionable recommendation>"],\n'
|
| 98 |
+
' "compliance_details": {\n'
|
| 99 |
+
' "msha_compliant": <true|false>,\n'
|
| 100 |
+
' "osha_compliant": <true|false>,\n'
|
| 101 |
+
' "dgms_compliant": <true|false>,\n'
|
| 102 |
+
' "missing_elements": ["<missing safety element>"]\n'
|
| 103 |
+
" },\n"
|
| 104 |
+
' "reasoning": {\n'
|
| 105 |
+
' "score_explanation": "<1-2 sentence explanation of why this specific score was assigned>",\n'
|
| 106 |
+
' "positive_factors": ["<safety elements that contributed positively to the score>"],\n'
|
| 107 |
+
' "negative_factors": ["<safety gaps or issues that reduced the score>"],\n'
|
| 108 |
+
' "evidence": [\n'
|
| 109 |
+
' {"text": "<exact quote or paraphrase from document>", "factor": "<positive|negative>", "impact": "<explanation>"}\n'
|
| 110 |
+
" ]\n"
|
| 111 |
+
" },\n"
|
| 112 |
+
' "summary": "<brief safety assessment summary>"\n'
|
| 113 |
+
"}\n\n"
|
| 114 |
+
"Scoring guide:\n"
|
| 115 |
+
" 80-100: Compliant, minimal concerns\n"
|
| 116 |
+
" 60-79: Generally compliant with warnings\n"
|
| 117 |
+
" 40-59: Significant concerns, needs attention\n"
|
| 118 |
+
" 0-39: Critical issues or violations present\n"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
result = await self._generate_json(prompt)
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"score": float(result.get("score") or 50),
|
| 125 |
+
"status": (result.get("status") or "pending").lower(),
|
| 126 |
+
"confidence": float(result.get("confidence") or 0.5),
|
| 127 |
+
"hazards": result.get("hazards", []),
|
| 128 |
+
"recommendations": result.get("recommendations", []),
|
| 129 |
+
"compliance_details": result.get("compliance_details", {}),
|
| 130 |
+
"reasoning": result.get(
|
| 131 |
+
"reasoning",
|
| 132 |
+
{
|
| 133 |
+
"score_explanation": "",
|
| 134 |
+
"positive_factors": [],
|
| 135 |
+
"negative_factors": [],
|
| 136 |
+
"evidence": [],
|
| 137 |
+
},
|
| 138 |
+
),
|
| 139 |
+
"summary": result.get("summary", ""),
|
| 140 |
+
}
|
app/agents/summarizer.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Summarizer Agent
|
| 3 |
+
Document summarization for mining documents
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from app.agents.base import BaseAgent
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SummarizerAgent(BaseAgent):
|
| 15 |
+
"""
|
| 16 |
+
Document Summarization Agent.
|
| 17 |
+
|
| 18 |
+
Creates concise, actionable summaries of mining documents:
|
| 19 |
+
- Executive summary
|
| 20 |
+
- Key points extraction
|
| 21 |
+
- Action items identification
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(self):
|
| 25 |
+
# Shift to Cerebras (gpt-oss-120b) to bypass Gemini API rate limits and avoid Groq parallel execution rate limits
|
| 26 |
+
super().__init__(model_name="gpt-oss-120b", provider="cerebras")
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def system_prompt(self) -> str:
|
| 30 |
+
return """You are a document summarization agent for the mining industry.
|
| 31 |
+
|
| 32 |
+
Create clear, actionable summaries that:
|
| 33 |
+
- Highlight critical information first
|
| 34 |
+
- Focus on safety-relevant content
|
| 35 |
+
- Identify action items and deadlines
|
| 36 |
+
- Use plain language accessible to all mining personnel
|
| 37 |
+
- Preserve technical accuracy
|
| 38 |
+
|
| 39 |
+
Summary structure:
|
| 40 |
+
1. Executive Summary: 2-3 paragraphs covering main purpose and findings
|
| 41 |
+
2. Key Points: 5-7 bullet points of most important information
|
| 42 |
+
3. Action Items: Any required actions or follow-ups (if applicable)
|
| 43 |
+
|
| 44 |
+
Prioritize:
|
| 45 |
+
- Safety information
|
| 46 |
+
- Compliance requirements
|
| 47 |
+
- Deadlines and schedules
|
| 48 |
+
- Equipment status
|
| 49 |
+
- Personnel responsibilities
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
async def analyze(
|
| 53 |
+
self, text: str, context: Optional[Dict] = None
|
| 54 |
+
) -> Dict[str, Any]:
|
| 55 |
+
"""
|
| 56 |
+
Generate document summary and key points.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
{
|
| 60 |
+
"summary": str,
|
| 61 |
+
"key_points": List[str],
|
| 62 |
+
"action_items": List[str],
|
| 63 |
+
"document_purpose": str,
|
| 64 |
+
"confidence": float
|
| 65 |
+
}
|
| 66 |
+
"""
|
| 67 |
+
category = context.get("category", "unknown") if context else "unknown"
|
| 68 |
+
|
| 69 |
+
prompt = f"""Summarize this mining document clearly and concisely.
|
| 70 |
+
|
| 71 |
+
Document Type: {category}
|
| 72 |
+
Document content ({len(text)} chars total, showing up to 15000):
|
| 73 |
+
{self._prepare_text(text)}
|
| 74 |
+
|
| 75 |
+
Respond with a JSON object:
|
| 76 |
+
{{
|
| 77 |
+
"summary": "<2-3 paragraph executive summary covering main purpose and findings>",
|
| 78 |
+
"key_points": [
|
| 79 |
+
"<Key point 1>",
|
| 80 |
+
"<Key point 2>",
|
| 81 |
+
"<Key point 3>",
|
| 82 |
+
"<Key point 4>",
|
| 83 |
+
"<Key point 5>"
|
| 84 |
+
],
|
| 85 |
+
"action_items": ["<required action or follow-up if any>"],
|
| 86 |
+
"document_purpose": "<one sentence describing the document's main purpose>",
|
| 87 |
+
"confidence": <0.0-1.0>
|
| 88 |
+
}}
|
| 89 |
+
"""
|
| 90 |
+
result = await self._generate_json(prompt)
|
| 91 |
+
|
| 92 |
+
summary = result.get("summary") or "Summary not available."
|
| 93 |
+
return {
|
| 94 |
+
"summary": summary,
|
| 95 |
+
"key_points": result.get("key_points", []),
|
| 96 |
+
"action_items": result.get("action_items", []),
|
| 97 |
+
"document_purpose": result.get("document_purpose", ""),
|
| 98 |
+
"confidence": float(result.get("confidence") or 0.7),
|
| 99 |
+
"word_count": len(summary.split()),
|
| 100 |
+
}
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Module
|
| 3 |
+
REST API endpoints organized by domain
|
| 4 |
+
"""
|
app/api/deps.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Dependencies
|
| 3 |
+
Shared dependencies for FastAPI endpoints
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import hashlib
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import Depends, Header, Request
|
| 11 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from app.core.exceptions import AuthenticationError
|
| 15 |
+
from app.core.security import extract_user_email, extract_user_id, verify_jwt_token
|
| 16 |
+
from app.db.session import SessionLocal, get_db
|
| 17 |
+
from app.models.audit import AuditAction, create_audit_log
|
| 18 |
+
from app.models.user import User
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _anonymize_user_id(user_id: str) -> str:
|
| 24 |
+
"""Create a truncated hash of user_id for safe logging."""
|
| 25 |
+
return hashlib.sha256(user_id.encode()).hexdigest()[:12]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Security scheme
|
| 29 |
+
security = HTTPBearer()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def get_current_user_id(
|
| 33 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 34 |
+
) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Dependency to extract and verify user ID from JWT.
|
| 37 |
+
Returns Clerk user ID string.
|
| 38 |
+
"""
|
| 39 |
+
token = credentials.credentials
|
| 40 |
+
payload = await verify_jwt_token(token)
|
| 41 |
+
return extract_user_id(payload)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def get_current_user(
|
| 45 |
+
user_id: str = Depends(get_current_user_id), db: Session = Depends(get_db)
|
| 46 |
+
) -> User:
|
| 47 |
+
"""
|
| 48 |
+
Dependency to get current user model from database.
|
| 49 |
+
Creates user record if it doesn't exist (first login).
|
| 50 |
+
"""
|
| 51 |
+
user = db.query(User).filter(User.clerk_user_id == user_id).first()
|
| 52 |
+
|
| 53 |
+
if not user:
|
| 54 |
+
# Auto-create user on first access
|
| 55 |
+
user = User(clerk_user_id=user_id, is_active=True)
|
| 56 |
+
db.add(user)
|
| 57 |
+
db.commit()
|
| 58 |
+
db.refresh(user)
|
| 59 |
+
logger.info(f"Created new user: {_anonymize_user_id(user_id)}")
|
| 60 |
+
|
| 61 |
+
return user
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def get_optional_user(
|
| 65 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
| 66 |
+
HTTPBearer(auto_error=False)
|
| 67 |
+
),
|
| 68 |
+
db: Session = Depends(get_db),
|
| 69 |
+
) -> Optional[User]:
|
| 70 |
+
"""
|
| 71 |
+
Dependency for endpoints that work with or without auth.
|
| 72 |
+
Returns User if authenticated, None otherwise.
|
| 73 |
+
"""
|
| 74 |
+
if not credentials:
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
token = credentials.credentials
|
| 79 |
+
payload = await verify_jwt_token(token)
|
| 80 |
+
user_id = extract_user_id(payload)
|
| 81 |
+
return db.query(User).filter(User.clerk_user_id == user_id).first()
|
| 82 |
+
except Exception:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_client_ip(request: Request) -> str:
|
| 87 |
+
"""Extract client IP address from request"""
|
| 88 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
| 89 |
+
if forwarded:
|
| 90 |
+
return forwarded.split(",")[0].strip()
|
| 91 |
+
return request.client.host if request.client else "unknown"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_user_agent(request: Request) -> str:
|
| 95 |
+
"""Extract user agent from request"""
|
| 96 |
+
return request.headers.get("User-Agent", "unknown")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def audit_middleware(
|
| 100 |
+
request: Request, user_id: str = Depends(get_current_user_id)
|
| 101 |
+
):
|
| 102 |
+
"""
|
| 103 |
+
Middleware-like dependency to log API access.
|
| 104 |
+
Add to endpoints that need audit logging.
|
| 105 |
+
"""
|
| 106 |
+
# This is called after auth, so we have user_id
|
| 107 |
+
# Actual logging happens in endpoint handlers
|
| 108 |
+
return {
|
| 109 |
+
"user_id": user_id,
|
| 110 |
+
"ip_address": get_client_ip(request),
|
| 111 |
+
"user_agent": get_user_agent(request),
|
| 112 |
+
}
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API v1 Module
|
| 3 |
+
Versioned API endpoints
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.api.v1.router import api_router
|
| 7 |
+
|
| 8 |
+
__all__ = ["api_router"]
|
app/api/v1/analytics.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analytics API Endpoints
|
| 3 |
+
Dashboard statistics and mining intelligence metrics
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, Query
|
| 10 |
+
from sqlalchemy import case, func
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.api.deps import get_current_user_id
|
| 14 |
+
from app.db.session import get_db
|
| 15 |
+
from app.models.chat import ChatMessage, ChatSession
|
| 16 |
+
from app.models.document import (
|
| 17 |
+
ComplianceStatus,
|
| 18 |
+
Document,
|
| 19 |
+
DocumentCategory,
|
| 20 |
+
DocumentStatus,
|
| 21 |
+
)
|
| 22 |
+
from app.schemas.analytics import (
|
| 23 |
+
CategoryCount,
|
| 24 |
+
DashboardStats,
|
| 25 |
+
DocumentAnalytics,
|
| 26 |
+
SafetyAnalytics,
|
| 27 |
+
SafetyDistribution,
|
| 28 |
+
StatusCount,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
router = APIRouter()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
import json
|
| 37 |
+
|
| 38 |
+
import redis
|
| 39 |
+
|
| 40 |
+
from app.config import settings
|
| 41 |
+
|
| 42 |
+
# Optional Redis client
|
| 43 |
+
try:
|
| 44 |
+
redis_client = redis.Redis.from_url(
|
| 45 |
+
settings.REDIS_URL, decode_responses=True, socket_timeout=1
|
| 46 |
+
)
|
| 47 |
+
except Exception:
|
| 48 |
+
redis_client = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/dashboard", response_model=DashboardStats)
|
| 52 |
+
async def get_dashboard_stats(
|
| 53 |
+
user_id: str = Depends(get_current_user_id), db: Session = Depends(get_db)
|
| 54 |
+
):
|
| 55 |
+
"""
|
| 56 |
+
Get dashboard statistics for the current user.
|
| 57 |
+
Provides overview of documents, chats, and safety metrics.
|
| 58 |
+
"""
|
| 59 |
+
cache_key = f"dashboard_stats:{user_id}"
|
| 60 |
+
|
| 61 |
+
if redis_client:
|
| 62 |
+
try:
|
| 63 |
+
cached_data = redis_client.get(cache_key)
|
| 64 |
+
if cached_data:
|
| 65 |
+
return DashboardStats.model_validate_json(cached_data)
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.warning(f"Redis cache read error: {e}")
|
| 68 |
+
|
| 69 |
+
now = datetime.utcnow()
|
| 70 |
+
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 71 |
+
week_start = today_start - timedelta(days=7)
|
| 72 |
+
|
| 73 |
+
# Document counts
|
| 74 |
+
doc_query = db.query(Document).filter(Document.user_id == user_id)
|
| 75 |
+
|
| 76 |
+
total_documents = doc_query.count()
|
| 77 |
+
processed_documents = doc_query.filter(
|
| 78 |
+
Document.status == DocumentStatus.COMPLETED
|
| 79 |
+
).count()
|
| 80 |
+
pending_documents = doc_query.filter(
|
| 81 |
+
Document.status.in_(
|
| 82 |
+
[
|
| 83 |
+
DocumentStatus.PENDING,
|
| 84 |
+
DocumentStatus.PROCESSING,
|
| 85 |
+
DocumentStatus.ANALYZING,
|
| 86 |
+
]
|
| 87 |
+
)
|
| 88 |
+
).count()
|
| 89 |
+
failed_documents = doc_query.filter(
|
| 90 |
+
Document.status == DocumentStatus.FAILED
|
| 91 |
+
).count()
|
| 92 |
+
|
| 93 |
+
# Today and week counts
|
| 94 |
+
docs_today = doc_query.filter(Document.created_at >= today_start).count()
|
| 95 |
+
docs_week = doc_query.filter(Document.created_at >= week_start).count()
|
| 96 |
+
|
| 97 |
+
# Chat counts
|
| 98 |
+
total_sessions = (
|
| 99 |
+
db.query(ChatSession).filter(ChatSession.user_id == user_id).count()
|
| 100 |
+
)
|
| 101 |
+
total_messages = (
|
| 102 |
+
db.query(ChatMessage)
|
| 103 |
+
.join(ChatSession)
|
| 104 |
+
.filter(ChatSession.user_id == user_id)
|
| 105 |
+
.count()
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Safety metrics
|
| 109 |
+
safety_stats = (
|
| 110 |
+
db.query(
|
| 111 |
+
func.avg(Document.safety_score).label("avg_score"),
|
| 112 |
+
func.count(case((Document.hazards_detected.isnot(None), 1))).label(
|
| 113 |
+
"with_hazards"
|
| 114 |
+
),
|
| 115 |
+
func.count(
|
| 116 |
+
case((Document.compliance_status == ComplianceStatus.VIOLATION, 1))
|
| 117 |
+
).label("violations"),
|
| 118 |
+
func.count(
|
| 119 |
+
case((Document.compliance_status == ComplianceStatus.WARNING, 1))
|
| 120 |
+
).label("warnings"),
|
| 121 |
+
)
|
| 122 |
+
.filter(
|
| 123 |
+
Document.user_id == user_id, Document.status == DocumentStatus.COMPLETED
|
| 124 |
+
)
|
| 125 |
+
.first()
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Category breakdown
|
| 129 |
+
category_counts = (
|
| 130 |
+
db.query(Document.category, func.count(Document.id).label("count"))
|
| 131 |
+
.filter(Document.user_id == user_id, Document.category.isnot(None))
|
| 132 |
+
.group_by(Document.category)
|
| 133 |
+
.all()
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
total_categorized = sum(c.count for c in category_counts)
|
| 137 |
+
categories = [
|
| 138 |
+
CategoryCount(
|
| 139 |
+
category=cat.value if cat else "other",
|
| 140 |
+
count=count,
|
| 141 |
+
percentage=(
|
| 142 |
+
round(count / total_categorized * 100, 1)
|
| 143 |
+
if total_categorized > 0
|
| 144 |
+
else 0
|
| 145 |
+
),
|
| 146 |
+
)
|
| 147 |
+
for cat, count in category_counts
|
| 148 |
+
]
|
| 149 |
+
|
| 150 |
+
# Last activity
|
| 151 |
+
last_doc = doc_query.order_by(Document.created_at.desc()).first()
|
| 152 |
+
last_chat = (
|
| 153 |
+
db.query(ChatSession)
|
| 154 |
+
.filter(ChatSession.user_id == user_id)
|
| 155 |
+
.order_by(ChatSession.updated_at.desc())
|
| 156 |
+
.first()
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
stats = DashboardStats(
|
| 160 |
+
total_documents=total_documents,
|
| 161 |
+
processed_documents=processed_documents,
|
| 162 |
+
pending_documents=pending_documents,
|
| 163 |
+
failed_documents=failed_documents,
|
| 164 |
+
total_chat_sessions=total_sessions,
|
| 165 |
+
total_messages=total_messages,
|
| 166 |
+
average_safety_score=(
|
| 167 |
+
round(safety_stats.avg_score, 1) if safety_stats.avg_score else None
|
| 168 |
+
),
|
| 169 |
+
documents_with_hazards=safety_stats.with_hazards or 0,
|
| 170 |
+
compliance_violations=safety_stats.violations or 0,
|
| 171 |
+
compliance_warnings=safety_stats.warnings or 0,
|
| 172 |
+
documents_processed_today=docs_today,
|
| 173 |
+
documents_processed_this_week=docs_week,
|
| 174 |
+
documents_by_category=categories,
|
| 175 |
+
last_upload_at=last_doc.created_at if last_doc else None,
|
| 176 |
+
last_chat_at=last_chat.updated_at if last_chat else None,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
if redis_client:
|
| 180 |
+
try:
|
| 181 |
+
# Cache for 60 seconds
|
| 182 |
+
redis_client.setex(cache_key, 60, stats.model_dump_json())
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.warning(f"Redis cache write error: {e}")
|
| 185 |
+
|
| 186 |
+
return stats
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.get("/documents", response_model=DocumentAnalytics)
|
| 190 |
+
async def get_document_analytics(
|
| 191 |
+
days: int = Query(30, ge=1, le=365),
|
| 192 |
+
user_id: str = Depends(get_current_user_id),
|
| 193 |
+
db: Session = Depends(get_db),
|
| 194 |
+
):
|
| 195 |
+
"""
|
| 196 |
+
Get detailed document analytics over time.
|
| 197 |
+
"""
|
| 198 |
+
start_date = datetime.utcnow() - timedelta(days=days)
|
| 199 |
+
|
| 200 |
+
# Uploads by day
|
| 201 |
+
uploads = (
|
| 202 |
+
db.query(
|
| 203 |
+
func.date(Document.created_at).label("date"),
|
| 204 |
+
func.count(Document.id).label("count"),
|
| 205 |
+
)
|
| 206 |
+
.filter(Document.user_id == user_id, Document.created_at >= start_date)
|
| 207 |
+
.group_by(func.date(Document.created_at))
|
| 208 |
+
.order_by("date")
|
| 209 |
+
.all()
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
uploads_by_day = [{"date": str(d), "count": c} for d, c in uploads]
|
| 213 |
+
|
| 214 |
+
# Category distribution
|
| 215 |
+
categories = (
|
| 216 |
+
db.query(Document.category, func.count(Document.id).label("count"))
|
| 217 |
+
.filter(Document.user_id == user_id, Document.category.isnot(None))
|
| 218 |
+
.group_by(Document.category)
|
| 219 |
+
.all()
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
total = sum(c for _, c in categories)
|
| 223 |
+
by_category = [
|
| 224 |
+
CategoryCount(
|
| 225 |
+
category=cat.value if cat else "other",
|
| 226 |
+
count=count,
|
| 227 |
+
percentage=round(count / total * 100, 1) if total > 0 else 0,
|
| 228 |
+
)
|
| 229 |
+
for cat, count in categories
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
# Status distribution
|
| 233 |
+
statuses = (
|
| 234 |
+
db.query(Document.status, func.count(Document.id).label("count"))
|
| 235 |
+
.filter(Document.user_id == user_id)
|
| 236 |
+
.group_by(Document.status)
|
| 237 |
+
.all()
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
by_status = [
|
| 241 |
+
StatusCount(status=s.value if s else "unknown", count=c) for s, c in statuses
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
# File type distribution
|
| 245 |
+
file_types = (
|
| 246 |
+
db.query(Document.file_type, func.count(Document.id).label("count"))
|
| 247 |
+
.filter(Document.user_id == user_id)
|
| 248 |
+
.group_by(Document.file_type)
|
| 249 |
+
.all()
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
by_file_type = [{"type": ft, "count": c} for ft, c in file_types]
|
| 253 |
+
|
| 254 |
+
return DocumentAnalytics(
|
| 255 |
+
uploads_by_day=uploads_by_day,
|
| 256 |
+
by_category=by_category,
|
| 257 |
+
by_status=by_status,
|
| 258 |
+
by_file_type=by_file_type,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@router.get("/safety", response_model=SafetyAnalytics)
|
| 263 |
+
async def get_safety_analytics(
|
| 264 |
+
user_id: str = Depends(get_current_user_id), db: Session = Depends(get_db)
|
| 265 |
+
):
|
| 266 |
+
"""
|
| 267 |
+
Get safety compliance analytics.
|
| 268 |
+
"""
|
| 269 |
+
# Base query for completed documents with safety scores
|
| 270 |
+
base = db.query(Document).filter(
|
| 271 |
+
Document.user_id == user_id,
|
| 272 |
+
Document.status == DocumentStatus.COMPLETED,
|
| 273 |
+
Document.safety_score.isnot(None),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Score statistics
|
| 277 |
+
stats = (
|
| 278 |
+
db.query(
|
| 279 |
+
func.avg(Document.safety_score).label("avg"),
|
| 280 |
+
func.min(Document.safety_score).label("min"),
|
| 281 |
+
func.max(Document.safety_score).label("max"),
|
| 282 |
+
)
|
| 283 |
+
.filter(Document.user_id == user_id, Document.safety_score.isnot(None))
|
| 284 |
+
.first()
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Score distribution
|
| 288 |
+
ranges = [
|
| 289 |
+
("0-25", 0, 25),
|
| 290 |
+
("26-50", 26, 50),
|
| 291 |
+
("51-75", 51, 75),
|
| 292 |
+
("76-100", 76, 100),
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
total_with_scores = base.count()
|
| 296 |
+
distribution = []
|
| 297 |
+
|
| 298 |
+
for label, low, high in ranges:
|
| 299 |
+
count = base.filter(
|
| 300 |
+
Document.safety_score >= low, Document.safety_score <= high
|
| 301 |
+
).count()
|
| 302 |
+
distribution.append(
|
| 303 |
+
SafetyDistribution(
|
| 304 |
+
range=label,
|
| 305 |
+
count=count,
|
| 306 |
+
percentage=(
|
| 307 |
+
round(count / total_with_scores * 100, 1)
|
| 308 |
+
if total_with_scores > 0
|
| 309 |
+
else 0
|
| 310 |
+
),
|
| 311 |
+
)
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
# Compliance counts
|
| 315 |
+
compliant = (
|
| 316 |
+
db.query(Document)
|
| 317 |
+
.filter(
|
| 318 |
+
Document.user_id == user_id,
|
| 319 |
+
Document.compliance_status == ComplianceStatus.COMPLIANT,
|
| 320 |
+
)
|
| 321 |
+
.count()
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
warnings = (
|
| 325 |
+
db.query(Document)
|
| 326 |
+
.filter(
|
| 327 |
+
Document.user_id == user_id,
|
| 328 |
+
Document.compliance_status == ComplianceStatus.WARNING,
|
| 329 |
+
)
|
| 330 |
+
.count()
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
violations = (
|
| 334 |
+
db.query(Document)
|
| 335 |
+
.filter(
|
| 336 |
+
Document.user_id == user_id,
|
| 337 |
+
Document.compliance_status == ComplianceStatus.VIOLATION,
|
| 338 |
+
)
|
| 339 |
+
.count()
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
return SafetyAnalytics(
|
| 343 |
+
average_safety_score=round(stats.avg, 1) if stats.avg else 0,
|
| 344 |
+
min_safety_score=stats.min,
|
| 345 |
+
max_safety_score=stats.max,
|
| 346 |
+
score_distribution=distribution,
|
| 347 |
+
compliant_count=compliant,
|
| 348 |
+
warning_count=warnings,
|
| 349 |
+
violation_count=violations,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@router.get("/violations")
|
| 354 |
+
async def get_recent_violations(
|
| 355 |
+
limit: int = Query(20, ge=1, le=100),
|
| 356 |
+
user_id: str = Depends(get_current_user_id),
|
| 357 |
+
db: Session = Depends(get_db),
|
| 358 |
+
):
|
| 359 |
+
"""
|
| 360 |
+
Get recent violations and warnings from analyzed documents.
|
| 361 |
+
Returns documents with detected hazards, ordered by severity and recency.
|
| 362 |
+
"""
|
| 363 |
+
# Get documents with violations or warnings that have hazards
|
| 364 |
+
docs = (
|
| 365 |
+
db.query(Document)
|
| 366 |
+
.filter(
|
| 367 |
+
Document.user_id == user_id,
|
| 368 |
+
Document.status == DocumentStatus.COMPLETED,
|
| 369 |
+
Document.compliance_status.in_(
|
| 370 |
+
[ComplianceStatus.VIOLATION, ComplianceStatus.WARNING]
|
| 371 |
+
),
|
| 372 |
+
Document.hazards_detected.isnot(None),
|
| 373 |
+
)
|
| 374 |
+
.order_by(Document.created_at.desc())
|
| 375 |
+
.limit(limit)
|
| 376 |
+
.all()
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
violations = []
|
| 380 |
+
for doc in docs:
|
| 381 |
+
hazards = doc.hazards_detected or []
|
| 382 |
+
if isinstance(hazards, list):
|
| 383 |
+
for h in hazards[:3]: # Max 3 hazards per doc
|
| 384 |
+
if isinstance(h, dict):
|
| 385 |
+
violations.append(
|
| 386 |
+
{
|
| 387 |
+
"document_id": str(doc.id),
|
| 388 |
+
"document_title": doc.title,
|
| 389 |
+
"file_name": doc.file_name,
|
| 390 |
+
"hazard_type": h.get("type", "Unknown Hazard"),
|
| 391 |
+
"severity": h.get("severity", "medium"),
|
| 392 |
+
"description": h.get("description", ""),
|
| 393 |
+
"regulation": h.get("regulation", ""),
|
| 394 |
+
"detected_at": (
|
| 395 |
+
doc.processed_at.isoformat()
|
| 396 |
+
if doc.processed_at
|
| 397 |
+
else doc.created_at.isoformat()
|
| 398 |
+
),
|
| 399 |
+
"compliance_status": (
|
| 400 |
+
doc.compliance_status.value
|
| 401 |
+
if doc.compliance_status
|
| 402 |
+
else "warning"
|
| 403 |
+
),
|
| 404 |
+
}
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
return {
|
| 408 |
+
"violations": violations,
|
| 409 |
+
"total": len(violations),
|
| 410 |
+
}
|
app/api/v1/chat.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat API Endpoints
|
| 3 |
+
Chat sessions and AI conversations with RAG
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from app.api.deps import get_current_user_id
|
| 15 |
+
from app.core.exceptions import NotFoundError
|
| 16 |
+
from app.db.session import get_db
|
| 17 |
+
from app.models.audit import AuditAction, create_audit_log
|
| 18 |
+
from app.models.chat import ChatMessage, ChatSession
|
| 19 |
+
from app.schemas.chat import (
|
| 20 |
+
ChatMessageResponse,
|
| 21 |
+
ChatRequest,
|
| 22 |
+
ChatResponse,
|
| 23 |
+
ChatSessionCreate,
|
| 24 |
+
ChatSessionDetailResponse,
|
| 25 |
+
ChatSessionResponse,
|
| 26 |
+
ChatSessionUpdateRequest,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/sessions", response_model=List[ChatSessionResponse])
|
| 35 |
+
async def list_chat_sessions(
|
| 36 |
+
limit: int = Query(50, ge=1, le=100),
|
| 37 |
+
user_id: str = Depends(get_current_user_id),
|
| 38 |
+
db: Session = Depends(get_db),
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
List user's chat sessions ordered by most recent.
|
| 42 |
+
"""
|
| 43 |
+
sessions = (
|
| 44 |
+
db.query(ChatSession)
|
| 45 |
+
.filter(ChatSession.user_id == user_id)
|
| 46 |
+
.order_by(ChatSession.updated_at.desc())
|
| 47 |
+
.limit(limit)
|
| 48 |
+
.all()
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
result = []
|
| 52 |
+
for session in sessions:
|
| 53 |
+
last_msg = session.messages[-1] if session.messages else None
|
| 54 |
+
result.append(
|
| 55 |
+
ChatSessionResponse(
|
| 56 |
+
id=str(session.id),
|
| 57 |
+
title=session.title,
|
| 58 |
+
message_count=len(session.messages),
|
| 59 |
+
document_context=session.document_context or [],
|
| 60 |
+
created_at=session.created_at,
|
| 61 |
+
updated_at=session.updated_at,
|
| 62 |
+
last_message=last_msg.content[:100] if last_msg else None,
|
| 63 |
+
last_message_at=last_msg.created_at if last_msg else None,
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return result
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.post("/sessions", response_model=ChatSessionResponse)
|
| 71 |
+
async def create_chat_session(
|
| 72 |
+
request: ChatSessionCreate,
|
| 73 |
+
user_id: str = Depends(get_current_user_id),
|
| 74 |
+
db: Session = Depends(get_db),
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Create a new chat session.
|
| 78 |
+
"""
|
| 79 |
+
session = ChatSession(
|
| 80 |
+
user_id=user_id,
|
| 81 |
+
title=request.title or "New Chat",
|
| 82 |
+
document_context=request.document_ids or [],
|
| 83 |
+
system_prompt=request.system_prompt,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
db.add(session)
|
| 87 |
+
db.commit()
|
| 88 |
+
db.refresh(session)
|
| 89 |
+
|
| 90 |
+
# Audit log
|
| 91 |
+
audit = create_audit_log(
|
| 92 |
+
action=AuditAction.CHAT_CREATE.value,
|
| 93 |
+
user_id=user_id,
|
| 94 |
+
resource_type="chat_session",
|
| 95 |
+
resource_id=str(session.id),
|
| 96 |
+
)
|
| 97 |
+
db.add(audit)
|
| 98 |
+
db.commit()
|
| 99 |
+
|
| 100 |
+
return ChatSessionResponse(
|
| 101 |
+
id=str(session.id),
|
| 102 |
+
title=session.title,
|
| 103 |
+
message_count=0,
|
| 104 |
+
document_context=session.document_context,
|
| 105 |
+
created_at=session.created_at,
|
| 106 |
+
updated_at=session.updated_at,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.get("/sessions/{session_id}", response_model=ChatSessionDetailResponse)
|
| 111 |
+
async def get_chat_session(
|
| 112 |
+
session_id: uuid.UUID,
|
| 113 |
+
user_id: str = Depends(get_current_user_id),
|
| 114 |
+
db: Session = Depends(get_db),
|
| 115 |
+
):
|
| 116 |
+
"""
|
| 117 |
+
Get chat session with all messages.
|
| 118 |
+
"""
|
| 119 |
+
session = (
|
| 120 |
+
db.query(ChatSession)
|
| 121 |
+
.filter(ChatSession.id == session_id, ChatSession.user_id == user_id)
|
| 122 |
+
.first()
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
if not session:
|
| 126 |
+
raise NotFoundError("Chat session", session_id)
|
| 127 |
+
|
| 128 |
+
messages = [
|
| 129 |
+
ChatMessageResponse(
|
| 130 |
+
id=str(msg.id),
|
| 131 |
+
role=msg.role,
|
| 132 |
+
content=msg.content,
|
| 133 |
+
sources=msg.sources or [],
|
| 134 |
+
created_at=msg.created_at,
|
| 135 |
+
model_used=msg.model_used,
|
| 136 |
+
response_time_ms=msg.response_time_ms,
|
| 137 |
+
)
|
| 138 |
+
for msg in session.messages
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
return ChatSessionDetailResponse(
|
| 142 |
+
id=str(session.id),
|
| 143 |
+
title=session.title,
|
| 144 |
+
document_context=session.document_context or [],
|
| 145 |
+
system_prompt=session.system_prompt,
|
| 146 |
+
messages=messages,
|
| 147 |
+
created_at=session.created_at,
|
| 148 |
+
updated_at=session.updated_at,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse)
|
| 153 |
+
async def update_chat_session(
|
| 154 |
+
session_id: uuid.UUID,
|
| 155 |
+
request: ChatSessionUpdateRequest,
|
| 156 |
+
user_id: str = Depends(get_current_user_id),
|
| 157 |
+
db: Session = Depends(get_db),
|
| 158 |
+
):
|
| 159 |
+
"""
|
| 160 |
+
Update chat session title or document context.
|
| 161 |
+
"""
|
| 162 |
+
session = (
|
| 163 |
+
db.query(ChatSession)
|
| 164 |
+
.filter(ChatSession.id == session_id, ChatSession.user_id == user_id)
|
| 165 |
+
.first()
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
if not session:
|
| 169 |
+
raise NotFoundError("Chat session", session_id)
|
| 170 |
+
|
| 171 |
+
if request.title is not None:
|
| 172 |
+
session.title = request.title
|
| 173 |
+
if request.document_ids is not None:
|
| 174 |
+
session.document_context = request.document_ids
|
| 175 |
+
|
| 176 |
+
session.updated_at = datetime.utcnow()
|
| 177 |
+
db.commit()
|
| 178 |
+
db.refresh(session)
|
| 179 |
+
|
| 180 |
+
return ChatSessionResponse(
|
| 181 |
+
id=str(session.id),
|
| 182 |
+
title=session.title,
|
| 183 |
+
message_count=len(session.messages),
|
| 184 |
+
document_context=session.document_context,
|
| 185 |
+
created_at=session.created_at,
|
| 186 |
+
updated_at=session.updated_at,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@router.delete("/sessions/{session_id}")
|
| 191 |
+
async def delete_chat_session(
|
| 192 |
+
session_id: uuid.UUID,
|
| 193 |
+
user_id: str = Depends(get_current_user_id),
|
| 194 |
+
db: Session = Depends(get_db),
|
| 195 |
+
):
|
| 196 |
+
"""
|
| 197 |
+
Delete a chat session and all messages.
|
| 198 |
+
"""
|
| 199 |
+
session = (
|
| 200 |
+
db.query(ChatSession)
|
| 201 |
+
.filter(ChatSession.id == session_id, ChatSession.user_id == user_id)
|
| 202 |
+
.first()
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if not session:
|
| 206 |
+
raise NotFoundError("Chat session", session_id)
|
| 207 |
+
|
| 208 |
+
# Audit log
|
| 209 |
+
audit = create_audit_log(
|
| 210 |
+
action=AuditAction.CHAT_DELETE.value,
|
| 211 |
+
user_id=user_id,
|
| 212 |
+
resource_type="chat_session",
|
| 213 |
+
resource_id=session_id,
|
| 214 |
+
)
|
| 215 |
+
db.add(audit)
|
| 216 |
+
|
| 217 |
+
db.delete(session)
|
| 218 |
+
db.commit()
|
| 219 |
+
|
| 220 |
+
return {"success": True, "message": "Chat session deleted"}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post("/send", response_model=ChatResponse)
|
| 224 |
+
async def send_message(
|
| 225 |
+
request: ChatRequest,
|
| 226 |
+
user_id: str = Depends(get_current_user_id),
|
| 227 |
+
db: Session = Depends(get_db),
|
| 228 |
+
):
|
| 229 |
+
"""
|
| 230 |
+
Send a message and get AI response.
|
| 231 |
+
Uses RAG to find relevant document context.
|
| 232 |
+
"""
|
| 233 |
+
start_time = datetime.utcnow()
|
| 234 |
+
|
| 235 |
+
# Get or create session
|
| 236 |
+
if request.session_id:
|
| 237 |
+
session = (
|
| 238 |
+
db.query(ChatSession)
|
| 239 |
+
.filter(
|
| 240 |
+
ChatSession.id == request.session_id, ChatSession.user_id == user_id
|
| 241 |
+
)
|
| 242 |
+
.first()
|
| 243 |
+
)
|
| 244 |
+
if not session:
|
| 245 |
+
raise NotFoundError("Chat session", request.session_id)
|
| 246 |
+
else:
|
| 247 |
+
# Create new session
|
| 248 |
+
session = ChatSession(user_id=user_id, title="New Chat")
|
| 249 |
+
db.add(session)
|
| 250 |
+
db.commit()
|
| 251 |
+
db.refresh(session)
|
| 252 |
+
|
| 253 |
+
# Save user message (but don't commit yet - wait for successful response)
|
| 254 |
+
user_message = ChatMessage(
|
| 255 |
+
session_id=session.id, role="user", content=request.content
|
| 256 |
+
)
|
| 257 |
+
db.add(user_message)
|
| 258 |
+
|
| 259 |
+
# Generate AI response with RAG
|
| 260 |
+
from app.services.chat_service import ChatService
|
| 261 |
+
|
| 262 |
+
chat_service = ChatService()
|
| 263 |
+
|
| 264 |
+
try:
|
| 265 |
+
ai_response, sources, tokens_used = await chat_service.generate_response(
|
| 266 |
+
query=request.content,
|
| 267 |
+
user_id=user_id,
|
| 268 |
+
document_ids=request.document_ids,
|
| 269 |
+
db=db,
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Calculate response time
|
| 273 |
+
end_time = datetime.utcnow()
|
| 274 |
+
response_time_ms = int((end_time - start_time).total_seconds() * 1000)
|
| 275 |
+
|
| 276 |
+
# Save assistant message
|
| 277 |
+
assistant_message = ChatMessage(
|
| 278 |
+
session_id=session.id,
|
| 279 |
+
role="assistant",
|
| 280 |
+
content=ai_response,
|
| 281 |
+
sources=sources if request.include_sources else [],
|
| 282 |
+
model_used="gemini-2.5-flash",
|
| 283 |
+
response_time_ms=response_time_ms,
|
| 284 |
+
tokens_used=tokens_used,
|
| 285 |
+
)
|
| 286 |
+
db.add(assistant_message)
|
| 287 |
+
|
| 288 |
+
# Get fresh message count from database
|
| 289 |
+
message_count = (
|
| 290 |
+
db.query(ChatMessage).filter(ChatMessage.session_id == session.id).count()
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Update session title if first/second message
|
| 294 |
+
if message_count <= 2:
|
| 295 |
+
# Auto-generate title from first user message
|
| 296 |
+
session.title = request.content[:50] + (
|
| 297 |
+
"..." if len(request.content) > 50 else ""
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
session.updated_at = datetime.utcnow()
|
| 301 |
+
|
| 302 |
+
# Audit log
|
| 303 |
+
audit = create_audit_log(
|
| 304 |
+
action=AuditAction.CHAT_MESSAGE.value,
|
| 305 |
+
user_id=user_id,
|
| 306 |
+
resource_type="chat_session",
|
| 307 |
+
resource_id=str(session.id),
|
| 308 |
+
details={"message_length": len(request.content)},
|
| 309 |
+
)
|
| 310 |
+
db.add(audit)
|
| 311 |
+
|
| 312 |
+
# Commit all changes atomically
|
| 313 |
+
db.commit()
|
| 314 |
+
db.refresh(assistant_message)
|
| 315 |
+
except Exception as e:
|
| 316 |
+
db.rollback()
|
| 317 |
+
raise
|
| 318 |
+
|
| 319 |
+
return ChatResponse(
|
| 320 |
+
message=ChatMessageResponse(
|
| 321 |
+
id=str(assistant_message.id),
|
| 322 |
+
role="assistant",
|
| 323 |
+
content=ai_response,
|
| 324 |
+
sources=sources if request.include_sources else [],
|
| 325 |
+
created_at=assistant_message.created_at,
|
| 326 |
+
model_used="gemini-2.5-flash",
|
| 327 |
+
response_time_ms=response_time_ms,
|
| 328 |
+
),
|
| 329 |
+
session_id=str(session.id),
|
| 330 |
+
session_title=session.title,
|
| 331 |
+
)
|
app/api/v1/chat_stream.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streaming Chat Endpoint (SSE)
|
| 3 |
+
Server-Sent Events streaming for real-time AI responses.
|
| 4 |
+
|
| 5 |
+
Delivers three event types to the client:
|
| 6 |
+
1. 'sources' — document citations (emitted first so UI renders immediately)
|
| 7 |
+
2. 'token' — streamed LLM response tokens
|
| 8 |
+
3. 'done' — signals stream completion with metadata
|
| 9 |
+
4. 'error' — on failure
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Depends, Query
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from sqlalchemy.orm import Session
|
| 20 |
+
|
| 21 |
+
from app.api.deps import get_current_user_id
|
| 22 |
+
from app.config import settings
|
| 23 |
+
from app.db.session import get_db
|
| 24 |
+
from app.models.chat import ChatMessage, ChatSession
|
| 25 |
+
from app.schemas.chat import ChatRequest
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/stream")
|
| 33 |
+
async def stream_chat(
|
| 34 |
+
request: ChatRequest,
|
| 35 |
+
user_id: str = Depends(get_current_user_id),
|
| 36 |
+
db: Session = Depends(get_db),
|
| 37 |
+
):
|
| 38 |
+
"""
|
| 39 |
+
Stream AI response using Server-Sent Events.
|
| 40 |
+
|
| 41 |
+
Event format:
|
| 42 |
+
event: sources
|
| 43 |
+
data: [{"document_title": ..., "file_name": ..., "page_numbers": [...], ...}]
|
| 44 |
+
|
| 45 |
+
event: token
|
| 46 |
+
data: {"text": "word by word..."}
|
| 47 |
+
|
| 48 |
+
event: done
|
| 49 |
+
data: {"session_id": "...", "sources_count": 3}
|
| 50 |
+
|
| 51 |
+
Usage with fetch():
|
| 52 |
+
const source = new EventSource('/api/v1/chat/stream', {...})
|
| 53 |
+
source.addEventListener('token', (e) => appendToken(JSON.parse(e.data).text))
|
| 54 |
+
source.addEventListener('sources', (e) => renderSources(JSON.parse(e.data)))
|
| 55 |
+
source.addEventListener('done', () => source.close())
|
| 56 |
+
"""
|
| 57 |
+
from app.services.chat_service import ChatService
|
| 58 |
+
|
| 59 |
+
# Get or create session
|
| 60 |
+
if request.session_id:
|
| 61 |
+
session = (
|
| 62 |
+
db.query(ChatSession)
|
| 63 |
+
.filter(
|
| 64 |
+
ChatSession.id == request.session_id, ChatSession.user_id == user_id
|
| 65 |
+
)
|
| 66 |
+
.first()
|
| 67 |
+
)
|
| 68 |
+
if not session:
|
| 69 |
+
from app.core.exceptions import NotFoundError
|
| 70 |
+
|
| 71 |
+
raise NotFoundError("Chat session", request.session_id)
|
| 72 |
+
else:
|
| 73 |
+
session = ChatSession(user_id=user_id, title="New Chat")
|
| 74 |
+
db.add(session)
|
| 75 |
+
db.commit()
|
| 76 |
+
db.refresh(session)
|
| 77 |
+
|
| 78 |
+
# Save user message immediately
|
| 79 |
+
user_message = ChatMessage(
|
| 80 |
+
session_id=session.id,
|
| 81 |
+
role="user",
|
| 82 |
+
content=request.content,
|
| 83 |
+
)
|
| 84 |
+
db.add(user_message)
|
| 85 |
+
db.commit()
|
| 86 |
+
|
| 87 |
+
chat_service = ChatService()
|
| 88 |
+
|
| 89 |
+
async def event_generator():
|
| 90 |
+
full_response = []
|
| 91 |
+
sources = []
|
| 92 |
+
tokens_used = None
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
async for event in chat_service.generate_response_stream(
|
| 96 |
+
query=request.content,
|
| 97 |
+
user_id=user_id,
|
| 98 |
+
document_ids=request.document_ids,
|
| 99 |
+
db=db,
|
| 100 |
+
):
|
| 101 |
+
# Forward each SSE event to client
|
| 102 |
+
yield event
|
| 103 |
+
|
| 104 |
+
# Track sources from the sources event for DB persistence
|
| 105 |
+
if event.startswith("event: sources\n"):
|
| 106 |
+
data_line = event.split("data: ", 1)[-1].strip()
|
| 107 |
+
try:
|
| 108 |
+
sources = json.loads(data_line)
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
# Accumulate tokens for DB persistence
|
| 113 |
+
elif event.startswith("event: token\n"):
|
| 114 |
+
data_line = event.split("data: ", 1)[-1].strip()
|
| 115 |
+
try:
|
| 116 |
+
token_data = json.loads(data_line)
|
| 117 |
+
full_response.append(token_data.get("text", ""))
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
|
| 121 |
+
elif event.startswith("event: done\n"):
|
| 122 |
+
data_line = event.split("data: ", 1)[-1].strip()
|
| 123 |
+
try:
|
| 124 |
+
done_data = json.loads(data_line)
|
| 125 |
+
tokens_used = done_data.get("tokens_used")
|
| 126 |
+
except Exception:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Streaming error: {e}", exc_info=True)
|
| 131 |
+
yield f"event: error\ndata: {json.dumps({'message': str(e)})}\n\n"
|
| 132 |
+
|
| 133 |
+
finally:
|
| 134 |
+
# Persist assistant message after stream completes
|
| 135 |
+
if full_response:
|
| 136 |
+
response_text = "".join(full_response)
|
| 137 |
+
assistant_message = ChatMessage(
|
| 138 |
+
session_id=session.id,
|
| 139 |
+
role="assistant",
|
| 140 |
+
content=response_text,
|
| 141 |
+
sources=sources if request.include_sources else [],
|
| 142 |
+
model_used=settings.GEMINI_MODEL,
|
| 143 |
+
tokens_used=tokens_used,
|
| 144 |
+
)
|
| 145 |
+
db.add(assistant_message)
|
| 146 |
+
|
| 147 |
+
# Auto-title on first message
|
| 148 |
+
msg_count = (
|
| 149 |
+
db.query(ChatMessage)
|
| 150 |
+
.filter(ChatMessage.session_id == session.id)
|
| 151 |
+
.count()
|
| 152 |
+
)
|
| 153 |
+
if msg_count <= 2:
|
| 154 |
+
session.title = request.content[:50] + (
|
| 155 |
+
"..." if len(request.content) > 50 else ""
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
session.updated_at = datetime.utcnow()
|
| 159 |
+
db.commit()
|
| 160 |
+
|
| 161 |
+
return StreamingResponse(
|
| 162 |
+
event_generator(),
|
| 163 |
+
media_type="text/event-stream",
|
| 164 |
+
headers={
|
| 165 |
+
"Cache-Control": "no-cache",
|
| 166 |
+
"Connection": "keep-alive",
|
| 167 |
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
| 168 |
+
},
|
| 169 |
+
)
|
app/api/v1/compliance.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compliance Audit API Endpoints
|
| 3 |
+
Regulatory compliance auto-auditor: cross-references operational documents
|
| 4 |
+
against regulatory documents to produce per-clause compliance matrices.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from uuid import UUID
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from app.api.deps import get_current_user_id
|
| 15 |
+
from app.db.session import get_db
|
| 16 |
+
from app.models.audit import AuditAction, create_audit_log
|
| 17 |
+
from app.models.compliance import AuditStatus, ComplianceAudit, ComplianceMatrixRow
|
| 18 |
+
from app.models.document import Document, DocumentCategory, DocumentStatus
|
| 19 |
+
from app.schemas.compliance import (
|
| 20 |
+
ComplianceAuditCreate,
|
| 21 |
+
ComplianceAuditDetailResponse,
|
| 22 |
+
ComplianceAuditListResponse,
|
| 23 |
+
ComplianceAuditResponse,
|
| 24 |
+
ComplianceMatrixRowResponse,
|
| 25 |
+
)
|
| 26 |
+
from app.services.queue import enqueue_compliance_task
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
router = APIRouter()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/audits", response_model=ComplianceAuditListResponse)
|
| 34 |
+
async def list_audits(
|
| 35 |
+
page: int = Query(default=1, ge=1),
|
| 36 |
+
page_size: int = Query(default=20, ge=1, le=100),
|
| 37 |
+
user_id: str = Depends(get_current_user_id),
|
| 38 |
+
db: Session = Depends(get_db),
|
| 39 |
+
):
|
| 40 |
+
"""List all compliance audits for the current user."""
|
| 41 |
+
offset = (page - 1) * page_size
|
| 42 |
+
|
| 43 |
+
query = (
|
| 44 |
+
db.query(ComplianceAudit)
|
| 45 |
+
.filter(ComplianceAudit.user_id == user_id)
|
| 46 |
+
.order_by(ComplianceAudit.created_at.desc())
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
total = query.count()
|
| 50 |
+
audits = query.offset(offset).limit(page_size).all()
|
| 51 |
+
|
| 52 |
+
return ComplianceAuditListResponse(
|
| 53 |
+
audits=[ComplianceAuditResponse.model_validate(a) for a in audits],
|
| 54 |
+
total=total,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post(
|
| 59 |
+
"/audits",
|
| 60 |
+
response_model=ComplianceAuditResponse,
|
| 61 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 62 |
+
)
|
| 63 |
+
async def create_audit(
|
| 64 |
+
data: ComplianceAuditCreate,
|
| 65 |
+
user_id: str = Depends(get_current_user_id),
|
| 66 |
+
db: Session = Depends(get_db),
|
| 67 |
+
):
|
| 68 |
+
"""Create and trigger a new compliance audit."""
|
| 69 |
+
# Validate regulation document exists and belongs to user
|
| 70 |
+
reg_doc = (
|
| 71 |
+
db.query(Document)
|
| 72 |
+
.filter(
|
| 73 |
+
Document.id == data.regulation_doc_id,
|
| 74 |
+
Document.user_id == user_id,
|
| 75 |
+
)
|
| 76 |
+
.first()
|
| 77 |
+
)
|
| 78 |
+
if not reg_doc:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 81 |
+
detail="Regulation document not found",
|
| 82 |
+
)
|
| 83 |
+
if reg_doc.status != DocumentStatus.COMPLETED:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 86 |
+
detail="Regulation document must be fully processed before auditing",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Validate operational documents
|
| 90 |
+
op_doc_ids = [str(d) for d in data.operational_doc_ids]
|
| 91 |
+
op_docs = (
|
| 92 |
+
db.query(Document)
|
| 93 |
+
.filter(
|
| 94 |
+
Document.id.in_(op_doc_ids),
|
| 95 |
+
Document.user_id == user_id,
|
| 96 |
+
)
|
| 97 |
+
.all()
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if len(op_docs) != len(op_doc_ids):
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 103 |
+
detail="One or more operational documents not found",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
for doc in op_docs:
|
| 107 |
+
if doc.status != DocumentStatus.COMPLETED:
|
| 108 |
+
raise HTTPException(
|
| 109 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 110 |
+
detail=f"Document '{doc.title}' must be fully processed first",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Create audit record
|
| 114 |
+
audit = ComplianceAudit(
|
| 115 |
+
user_id=user_id,
|
| 116 |
+
title=data.title,
|
| 117 |
+
regulation_doc_id=data.regulation_doc_id,
|
| 118 |
+
operational_doc_ids=op_doc_ids,
|
| 119 |
+
status=AuditStatus.PENDING,
|
| 120 |
+
)
|
| 121 |
+
db.add(audit)
|
| 122 |
+
db.commit()
|
| 123 |
+
db.refresh(audit)
|
| 124 |
+
|
| 125 |
+
# Audit log
|
| 126 |
+
log = create_audit_log(
|
| 127 |
+
user_id=user_id,
|
| 128 |
+
action=AuditAction.DOCUMENT_UPLOAD,
|
| 129 |
+
resource_type="compliance_audit",
|
| 130 |
+
resource_id=str(audit.id),
|
| 131 |
+
details={"title": data.title, "regulation_doc": str(data.regulation_doc_id)},
|
| 132 |
+
)
|
| 133 |
+
db.add(log)
|
| 134 |
+
db.commit()
|
| 135 |
+
|
| 136 |
+
# Enqueue for background processing
|
| 137 |
+
await enqueue_compliance_task(str(audit.id))
|
| 138 |
+
|
| 139 |
+
logger.info(
|
| 140 |
+
f"Compliance audit created: {audit.id} — "
|
| 141 |
+
f"reg_doc={data.regulation_doc_id}, op_docs={len(op_doc_ids)}"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return ComplianceAuditResponse.model_validate(audit)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.get("/audits/{audit_id}", response_model=ComplianceAuditDetailResponse)
|
| 148 |
+
async def get_audit(
|
| 149 |
+
audit_id: UUID,
|
| 150 |
+
user_id: str = Depends(get_current_user_id),
|
| 151 |
+
db: Session = Depends(get_db),
|
| 152 |
+
):
|
| 153 |
+
"""Get a compliance audit with full matrix rows."""
|
| 154 |
+
audit = (
|
| 155 |
+
db.query(ComplianceAudit)
|
| 156 |
+
.filter(
|
| 157 |
+
ComplianceAudit.id == audit_id,
|
| 158 |
+
ComplianceAudit.user_id == user_id,
|
| 159 |
+
)
|
| 160 |
+
.first()
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if not audit:
|
| 164 |
+
raise HTTPException(
|
| 165 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 166 |
+
detail="Audit not found",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
rows = (
|
| 170 |
+
db.query(ComplianceMatrixRow)
|
| 171 |
+
.filter(ComplianceMatrixRow.audit_id == audit.id)
|
| 172 |
+
.order_by(ComplianceMatrixRow.clause_index)
|
| 173 |
+
.all()
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return ComplianceAuditDetailResponse(
|
| 177 |
+
**ComplianceAuditResponse.model_validate(audit).model_dump(),
|
| 178 |
+
rows=[ComplianceMatrixRowResponse.model_validate(r) for r in rows],
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@router.delete("/audits/{audit_id}")
|
| 183 |
+
async def delete_audit(
|
| 184 |
+
audit_id: UUID,
|
| 185 |
+
user_id: str = Depends(get_current_user_id),
|
| 186 |
+
db: Session = Depends(get_db),
|
| 187 |
+
):
|
| 188 |
+
"""Delete a compliance audit and all its matrix rows."""
|
| 189 |
+
audit = (
|
| 190 |
+
db.query(ComplianceAudit)
|
| 191 |
+
.filter(
|
| 192 |
+
ComplianceAudit.id == audit_id,
|
| 193 |
+
ComplianceAudit.user_id == user_id,
|
| 194 |
+
)
|
| 195 |
+
.first()
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if not audit:
|
| 199 |
+
raise HTTPException(
|
| 200 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 201 |
+
detail="Audit not found",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
audit_title = audit.title
|
| 205 |
+
db.delete(audit)
|
| 206 |
+
db.commit()
|
| 207 |
+
|
| 208 |
+
log = create_audit_log(
|
| 209 |
+
user_id=user_id,
|
| 210 |
+
action=AuditAction.DOCUMENT_DELETE,
|
| 211 |
+
resource_type="compliance_audit",
|
| 212 |
+
resource_id=str(audit_id),
|
| 213 |
+
details={"title": audit_title},
|
| 214 |
+
)
|
| 215 |
+
db.add(log)
|
| 216 |
+
db.commit()
|
| 217 |
+
|
| 218 |
+
return {"message": "Audit deleted"}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/audits/{audit_id}/export")
|
| 222 |
+
async def export_audit(
|
| 223 |
+
audit_id: UUID,
|
| 224 |
+
user_id: str = Depends(get_current_user_id),
|
| 225 |
+
db: Session = Depends(get_db),
|
| 226 |
+
):
|
| 227 |
+
"""Export audit results as structured JSON."""
|
| 228 |
+
audit = (
|
| 229 |
+
db.query(ComplianceAudit)
|
| 230 |
+
.filter(
|
| 231 |
+
ComplianceAudit.id == audit_id,
|
| 232 |
+
ComplianceAudit.user_id == user_id,
|
| 233 |
+
)
|
| 234 |
+
.first()
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if not audit:
|
| 238 |
+
raise HTTPException(
|
| 239 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 240 |
+
detail="Audit not found",
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
rows = (
|
| 244 |
+
db.query(ComplianceMatrixRow)
|
| 245 |
+
.filter(ComplianceMatrixRow.audit_id == audit.id)
|
| 246 |
+
.order_by(ComplianceMatrixRow.clause_index)
|
| 247 |
+
.all()
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"audit": audit.to_dict(),
|
| 252 |
+
"matrix": [
|
| 253 |
+
{
|
| 254 |
+
"clause_index": r.clause_index,
|
| 255 |
+
"clause_text": r.clause_text,
|
| 256 |
+
"section_title": r.section_title,
|
| 257 |
+
"status": r.status,
|
| 258 |
+
"assessment": r.assessment,
|
| 259 |
+
"confidence": r.confidence,
|
| 260 |
+
"evidence_chunks": r.evidence_chunks or [],
|
| 261 |
+
"recommendations": r.recommendations or [],
|
| 262 |
+
}
|
| 263 |
+
for r in rows
|
| 264 |
+
],
|
| 265 |
+
}
|
app/api/v1/documents.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document API Endpoints
|
| 3 |
+
Document upload, management, and analysis
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
| 12 |
+
from sqlalchemy import func
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
|
| 15 |
+
from app.api.deps import audit_middleware, get_current_user, get_current_user_id
|
| 16 |
+
from app.core.exceptions import NotFoundError
|
| 17 |
+
from app.db.session import get_db
|
| 18 |
+
from app.models.audit import AuditAction, create_audit_log
|
| 19 |
+
from app.models.document import (
|
| 20 |
+
ComplianceStatus,
|
| 21 |
+
Document,
|
| 22 |
+
DocumentCategory,
|
| 23 |
+
DocumentStatus,
|
| 24 |
+
)
|
| 25 |
+
from app.models.user import User
|
| 26 |
+
from app.schemas.document import (
|
| 27 |
+
DocumentAnalysisResponse,
|
| 28 |
+
DocumentCreate,
|
| 29 |
+
DocumentListResponse,
|
| 30 |
+
DocumentResponse,
|
| 31 |
+
DocumentUploadResponse,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
router = APIRouter()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("", response_model=DocumentListResponse)
|
| 40 |
+
async def list_documents(
|
| 41 |
+
page: int = Query(1, ge=1),
|
| 42 |
+
page_size: int = Query(20, ge=1, le=100),
|
| 43 |
+
category: Optional[DocumentCategory] = None,
|
| 44 |
+
status: Optional[DocumentStatus] = None,
|
| 45 |
+
search: Optional[str] = None,
|
| 46 |
+
user_id: str = Depends(get_current_user_id),
|
| 47 |
+
db: Session = Depends(get_db),
|
| 48 |
+
):
|
| 49 |
+
"""
|
| 50 |
+
List user's documents with filtering and pagination.
|
| 51 |
+
"""
|
| 52 |
+
query = db.query(Document).filter(Document.user_id == user_id)
|
| 53 |
+
|
| 54 |
+
# Apply filters
|
| 55 |
+
if category:
|
| 56 |
+
query = query.filter(Document.category == category)
|
| 57 |
+
if status:
|
| 58 |
+
query = query.filter(Document.status == status)
|
| 59 |
+
if search:
|
| 60 |
+
# Escape SQL wildcard characters to prevent injection
|
| 61 |
+
escaped_search = (
|
| 62 |
+
search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
| 63 |
+
)
|
| 64 |
+
safe_pattern = f"%{escaped_search}%"
|
| 65 |
+
query = query.filter(
|
| 66 |
+
Document.title.ilike(safe_pattern, escape="\\")
|
| 67 |
+
| Document.file_name.ilike(safe_pattern, escape="\\")
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Get total count
|
| 71 |
+
total = query.count()
|
| 72 |
+
|
| 73 |
+
# Apply pagination
|
| 74 |
+
offset = (page - 1) * page_size
|
| 75 |
+
documents = (
|
| 76 |
+
query.order_by(Document.created_at.desc()).offset(offset).limit(page_size).all()
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Calculate stats
|
| 80 |
+
stats = _calculate_document_stats(db, user_id)
|
| 81 |
+
|
| 82 |
+
return DocumentListResponse(
|
| 83 |
+
documents=[DocumentResponse(**doc.to_dict()) for doc in documents],
|
| 84 |
+
total=total,
|
| 85 |
+
page=page,
|
| 86 |
+
page_size=page_size,
|
| 87 |
+
stats=stats,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post(
|
| 92 |
+
"", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED
|
| 93 |
+
)
|
| 94 |
+
async def create_document(
|
| 95 |
+
request: DocumentCreate,
|
| 96 |
+
user_id: str = Depends(get_current_user_id),
|
| 97 |
+
db: Session = Depends(get_db),
|
| 98 |
+
):
|
| 99 |
+
"""
|
| 100 |
+
Create a new document from UploadThing URL.
|
| 101 |
+
Triggers background processing with AI analysis via task queue.
|
| 102 |
+
"""
|
| 103 |
+
# Create document record
|
| 104 |
+
document = Document(
|
| 105 |
+
user_id=user_id,
|
| 106 |
+
title=request.title or request.file_name.rsplit(".", 1)[0],
|
| 107 |
+
file_name=request.file_name,
|
| 108 |
+
file_size=request.file_size,
|
| 109 |
+
file_type=request.file_type,
|
| 110 |
+
file_url=request.file_url,
|
| 111 |
+
status=DocumentStatus.PENDING,
|
| 112 |
+
tags=request.tags or [],
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
db.add(document)
|
| 116 |
+
db.flush() # Flush to get the document.id without committing
|
| 117 |
+
|
| 118 |
+
# Create audit log
|
| 119 |
+
audit = create_audit_log(
|
| 120 |
+
action=AuditAction.DOCUMENT_UPLOAD.value,
|
| 121 |
+
user_id=user_id,
|
| 122 |
+
resource_type="document",
|
| 123 |
+
resource_id=str(document.id),
|
| 124 |
+
details={
|
| 125 |
+
"file_name": document.file_name,
|
| 126 |
+
"file_size": document.file_size,
|
| 127 |
+
"file_type": document.file_type,
|
| 128 |
+
},
|
| 129 |
+
)
|
| 130 |
+
db.add(audit)
|
| 131 |
+
|
| 132 |
+
# Commit both document and audit atomically
|
| 133 |
+
db.commit()
|
| 134 |
+
db.refresh(document)
|
| 135 |
+
|
| 136 |
+
# Trigger background processing
|
| 137 |
+
from app.services.queue import enqueue_document_task
|
| 138 |
+
|
| 139 |
+
enqueue_document_task(str(document.id))
|
| 140 |
+
|
| 141 |
+
logger.info(f"Document created and enqueued: {document.id} - {document.title}")
|
| 142 |
+
|
| 143 |
+
return DocumentUploadResponse(
|
| 144 |
+
id=str(document.id),
|
| 145 |
+
title=document.title,
|
| 146 |
+
file_name=document.file_name,
|
| 147 |
+
status=DocumentStatus.PENDING,
|
| 148 |
+
job_id=str(document.id), # Using doc ID as job ID for now
|
| 149 |
+
message="Document uploaded successfully. AI analysis queued.",
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 154 |
+
async def get_document(
|
| 155 |
+
document_id: uuid.UUID,
|
| 156 |
+
user_id: str = Depends(get_current_user_id),
|
| 157 |
+
db: Session = Depends(get_db),
|
| 158 |
+
):
|
| 159 |
+
"""
|
| 160 |
+
Get document details by ID.
|
| 161 |
+
"""
|
| 162 |
+
document = (
|
| 163 |
+
db.query(Document)
|
| 164 |
+
.filter(Document.id == document_id, Document.user_id == user_id)
|
| 165 |
+
.first()
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
if not document:
|
| 169 |
+
raise NotFoundError("Document", document_id)
|
| 170 |
+
|
| 171 |
+
return DocumentResponse(**document.to_dict())
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.delete("/{document_id}")
|
| 175 |
+
async def delete_document(
|
| 176 |
+
document_id: uuid.UUID,
|
| 177 |
+
user_id: str = Depends(get_current_user_id),
|
| 178 |
+
db: Session = Depends(get_db),
|
| 179 |
+
):
|
| 180 |
+
"""
|
| 181 |
+
Delete a document and all associated data.
|
| 182 |
+
"""
|
| 183 |
+
document = (
|
| 184 |
+
db.query(Document)
|
| 185 |
+
.filter(Document.id == document_id, Document.user_id == user_id)
|
| 186 |
+
.first()
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
if not document:
|
| 190 |
+
raise NotFoundError("Document", document_id)
|
| 191 |
+
|
| 192 |
+
# Create audit log before deletion
|
| 193 |
+
audit = create_audit_log(
|
| 194 |
+
action=AuditAction.DOCUMENT_DELETE.value,
|
| 195 |
+
user_id=user_id,
|
| 196 |
+
resource_type="document",
|
| 197 |
+
resource_id=str(document_id),
|
| 198 |
+
details={"file_name": document.file_name},
|
| 199 |
+
)
|
| 200 |
+
db.add(audit)
|
| 201 |
+
|
| 202 |
+
# Delete document (cascade will handle embeddings)
|
| 203 |
+
db.delete(document)
|
| 204 |
+
db.commit()
|
| 205 |
+
|
| 206 |
+
logger.info(f"Document deleted: {document_id}")
|
| 207 |
+
|
| 208 |
+
return {"success": True, "message": "Document deleted successfully"}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@router.get("/{document_id}/analysis", response_model=DocumentAnalysisResponse)
|
| 212 |
+
async def get_document_analysis(
|
| 213 |
+
document_id: uuid.UUID,
|
| 214 |
+
user_id: str = Depends(get_current_user_id),
|
| 215 |
+
db: Session = Depends(get_db),
|
| 216 |
+
):
|
| 217 |
+
"""
|
| 218 |
+
Get AI analysis results for a document.
|
| 219 |
+
"""
|
| 220 |
+
document = (
|
| 221 |
+
db.query(Document)
|
| 222 |
+
.filter(Document.id == document_id, Document.user_id == user_id)
|
| 223 |
+
.first()
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
if not document:
|
| 227 |
+
raise NotFoundError("Document", document_id)
|
| 228 |
+
|
| 229 |
+
if document.status != DocumentStatus.COMPLETED:
|
| 230 |
+
return DocumentAnalysisResponse(
|
| 231 |
+
document_id=str(document.id), status=document.status.value, analysis=None
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
analysis = {
|
| 235 |
+
"category": document.category,
|
| 236 |
+
"subcategory": document.subcategory,
|
| 237 |
+
"classification_confidence": document.classification_confidence,
|
| 238 |
+
"summary": document.summary,
|
| 239 |
+
"key_points": document.key_points or [],
|
| 240 |
+
"safety_score": document.safety_score,
|
| 241 |
+
"compliance_status": document.compliance_status,
|
| 242 |
+
"hazards_detected": document.hazards_detected or [],
|
| 243 |
+
"safety_recommendations": document.safety_recommendations or [],
|
| 244 |
+
"entities": {
|
| 245 |
+
k: v if isinstance(v, list) else []
|
| 246 |
+
for k, v in (document.entities or {}).items()
|
| 247 |
+
},
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return DocumentAnalysisResponse(
|
| 251 |
+
document_id=str(document.id), status="completed", analysis=analysis
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.post("/{document_id}/reanalyze", status_code=status.HTTP_202_ACCEPTED)
|
| 256 |
+
async def reanalyze_document(
|
| 257 |
+
document_id: uuid.UUID,
|
| 258 |
+
user_id: str = Depends(get_current_user_id),
|
| 259 |
+
db: Session = Depends(get_db),
|
| 260 |
+
):
|
| 261 |
+
"""
|
| 262 |
+
Trigger re-analysis of a document.
|
| 263 |
+
"""
|
| 264 |
+
document = (
|
| 265 |
+
db.query(Document)
|
| 266 |
+
.filter(Document.id == document_id, Document.user_id == user_id)
|
| 267 |
+
.first()
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if not document:
|
| 271 |
+
raise NotFoundError("Document", document_id)
|
| 272 |
+
|
| 273 |
+
# Reset status
|
| 274 |
+
document.status = DocumentStatus.PENDING
|
| 275 |
+
db.commit()
|
| 276 |
+
|
| 277 |
+
# Trigger reprocessing
|
| 278 |
+
from app.services.queue import enqueue_document_task
|
| 279 |
+
|
| 280 |
+
enqueue_document_task(str(document.id))
|
| 281 |
+
|
| 282 |
+
return {"success": True, "message": "Document reanalysis queued"}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _calculate_document_stats(db: Session, user_id: str) -> dict:
|
| 286 |
+
"""Calculate aggregated statistics for user's documents"""
|
| 287 |
+
# Category distribution
|
| 288 |
+
category_counts = (
|
| 289 |
+
db.query(Document.category, func.count(Document.id))
|
| 290 |
+
.filter(Document.user_id == user_id, Document.category.isnot(None))
|
| 291 |
+
.group_by(Document.category)
|
| 292 |
+
.all()
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
by_category = {
|
| 296 |
+
cat.value if cat else "other": count for cat, count in category_counts
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
# Status distribution
|
| 300 |
+
status_counts = (
|
| 301 |
+
db.query(Document.status, func.count(Document.id))
|
| 302 |
+
.filter(Document.user_id == user_id)
|
| 303 |
+
.group_by(Document.status)
|
| 304 |
+
.all()
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
by_status = {
|
| 308 |
+
status.value if status else "unknown": count for status, count in status_counts
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
# Average safety score
|
| 312 |
+
avg_score = (
|
| 313 |
+
db.query(func.avg(Document.safety_score))
|
| 314 |
+
.filter(Document.user_id == user_id, Document.safety_score.isnot(None))
|
| 315 |
+
.scalar()
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
return {
|
| 319 |
+
"by_category": by_category,
|
| 320 |
+
"by_status": by_status,
|
| 321 |
+
"avg_safety_score": round(avg_score, 2) if avg_score else None,
|
| 322 |
+
}
|
app/api/v1/health.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Health Check Endpoints
|
| 3 |
+
System health and status monitoring
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.db.session import check_db_connection, get_db
|
| 13 |
+
from app.schemas.common import HealthResponse
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/", response_model=HealthResponse)
|
| 19 |
+
async def root():
|
| 20 |
+
"""Root endpoint - basic health check"""
|
| 21 |
+
return HealthResponse(
|
| 22 |
+
status="healthy",
|
| 23 |
+
version=settings.APP_VERSION,
|
| 24 |
+
environment=settings.ENVIRONMENT,
|
| 25 |
+
timestamp=datetime.utcnow(),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/health", response_model=HealthResponse)
|
| 30 |
+
async def health_check(db: Session = Depends(get_db)):
|
| 31 |
+
"""
|
| 32 |
+
Detailed health check with service status.
|
| 33 |
+
Checks database connectivity and other services.
|
| 34 |
+
"""
|
| 35 |
+
services = {"database": "unknown", "redis": "unknown", "ai": "unknown"}
|
| 36 |
+
|
| 37 |
+
# Check database
|
| 38 |
+
try:
|
| 39 |
+
from sqlalchemy import text
|
| 40 |
+
|
| 41 |
+
db.execute(text("SELECT 1"))
|
| 42 |
+
services["database"] = "healthy"
|
| 43 |
+
except Exception as e:
|
| 44 |
+
services["database"] = f"unhealthy: {str(e)}"
|
| 45 |
+
|
| 46 |
+
# Check Redis (if configured)
|
| 47 |
+
r = None
|
| 48 |
+
try:
|
| 49 |
+
import redis
|
| 50 |
+
|
| 51 |
+
r = redis.from_url(settings.REDIS_URL)
|
| 52 |
+
r.ping()
|
| 53 |
+
services["redis"] = "healthy"
|
| 54 |
+
except Exception:
|
| 55 |
+
services["redis"] = "not_configured"
|
| 56 |
+
finally:
|
| 57 |
+
if r is not None:
|
| 58 |
+
r.close()
|
| 59 |
+
|
| 60 |
+
# Check AI service (Gemini)
|
| 61 |
+
try:
|
| 62 |
+
import google.generativeai as genai
|
| 63 |
+
|
| 64 |
+
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 65 |
+
services["ai"] = "healthy"
|
| 66 |
+
except Exception:
|
| 67 |
+
services["ai"] = "not_configured"
|
| 68 |
+
|
| 69 |
+
# Overall status
|
| 70 |
+
overall = "healthy"
|
| 71 |
+
if services["database"] != "healthy":
|
| 72 |
+
overall = "degraded"
|
| 73 |
+
|
| 74 |
+
return HealthResponse(
|
| 75 |
+
status=overall,
|
| 76 |
+
version=settings.APP_VERSION,
|
| 77 |
+
environment=settings.ENVIRONMENT,
|
| 78 |
+
timestamp=datetime.utcnow(),
|
| 79 |
+
services=services,
|
| 80 |
+
)
|
app/api/v1/jobs.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Jobs API Endpoints
|
| 3 |
+
Background job status tracking
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Query
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.api.deps import get_current_user_id
|
| 12 |
+
from app.core.exceptions import NotFoundError
|
| 13 |
+
from app.db.session import get_db
|
| 14 |
+
from app.models.document import Document, DocumentStatus
|
| 15 |
+
from app.schemas.common import JobStatusResponse
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/{job_id}", response_model=JobStatusResponse)
|
| 23 |
+
async def get_job_status(
|
| 24 |
+
job_id: str,
|
| 25 |
+
user_id: str = Depends(get_current_user_id),
|
| 26 |
+
db: Session = Depends(get_db),
|
| 27 |
+
):
|
| 28 |
+
"""
|
| 29 |
+
Get status of a background processing job.
|
| 30 |
+
Currently jobs are tracked via document ID.
|
| 31 |
+
"""
|
| 32 |
+
# For now, job_id is document_id
|
| 33 |
+
document = (
|
| 34 |
+
db.query(Document)
|
| 35 |
+
.filter(Document.id == job_id, Document.user_id == user_id)
|
| 36 |
+
.first()
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if not document:
|
| 40 |
+
raise NotFoundError("Job", job_id)
|
| 41 |
+
|
| 42 |
+
# Map document status to job status
|
| 43 |
+
status_map = {
|
| 44 |
+
DocumentStatus.PENDING: "pending",
|
| 45 |
+
DocumentStatus.PROCESSING: "processing",
|
| 46 |
+
DocumentStatus.ANALYZING: "processing",
|
| 47 |
+
DocumentStatus.COMPLETED: "completed",
|
| 48 |
+
DocumentStatus.FAILED: "failed",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# Calculate progress based on status
|
| 52 |
+
progress_map = {
|
| 53 |
+
DocumentStatus.PENDING: 0,
|
| 54 |
+
DocumentStatus.PROCESSING: 30,
|
| 55 |
+
DocumentStatus.ANALYZING: 70,
|
| 56 |
+
DocumentStatus.COMPLETED: 100,
|
| 57 |
+
DocumentStatus.FAILED: 0,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
result = None
|
| 61 |
+
if document.status == DocumentStatus.COMPLETED:
|
| 62 |
+
result = {
|
| 63 |
+
"document_id": str(document.id),
|
| 64 |
+
"category": document.category.value if document.category else None,
|
| 65 |
+
"safety_score": document.safety_score,
|
| 66 |
+
"summary_preview": document.summary[:200] if document.summary else None,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return JobStatusResponse(
|
| 70 |
+
job_id=str(document.id),
|
| 71 |
+
status=status_map.get(document.status, "unknown"),
|
| 72 |
+
progress=progress_map.get(document.status, 0),
|
| 73 |
+
result=result,
|
| 74 |
+
error=document.processing_error,
|
| 75 |
+
created_at=document.created_at,
|
| 76 |
+
updated_at=document.updated_at,
|
| 77 |
+
completed_at=document.processed_at,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("")
|
| 82 |
+
async def list_active_jobs(
|
| 83 |
+
user_id: str = Depends(get_current_user_id), db: Session = Depends(get_db)
|
| 84 |
+
):
|
| 85 |
+
"""
|
| 86 |
+
List all active (non-completed) processing jobs.
|
| 87 |
+
"""
|
| 88 |
+
active_docs = (
|
| 89 |
+
db.query(Document)
|
| 90 |
+
.filter(
|
| 91 |
+
Document.user_id == user_id,
|
| 92 |
+
Document.status.in_(
|
| 93 |
+
[
|
| 94 |
+
DocumentStatus.PENDING,
|
| 95 |
+
DocumentStatus.PROCESSING,
|
| 96 |
+
DocumentStatus.ANALYZING,
|
| 97 |
+
]
|
| 98 |
+
),
|
| 99 |
+
)
|
| 100 |
+
.order_by(Document.created_at.desc())
|
| 101 |
+
.all()
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
jobs = []
|
| 105 |
+
for doc in active_docs:
|
| 106 |
+
progress = {
|
| 107 |
+
DocumentStatus.PENDING: 0,
|
| 108 |
+
DocumentStatus.PROCESSING: 30,
|
| 109 |
+
DocumentStatus.ANALYZING: 70,
|
| 110 |
+
}.get(doc.status, 0)
|
| 111 |
+
|
| 112 |
+
jobs.append(
|
| 113 |
+
{
|
| 114 |
+
"job_id": str(doc.id),
|
| 115 |
+
"document_title": doc.title,
|
| 116 |
+
"status": doc.status.value,
|
| 117 |
+
"progress": progress,
|
| 118 |
+
"created_at": doc.created_at.isoformat(),
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return {"jobs": jobs, "count": len(jobs)}
|
app/api/v1/prompts.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom Prompts API Endpoints
|
| 3 |
+
CRUD operations for user-defined AI prompts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, Query
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from app.api.deps import get_current_user, get_current_user_id
|
| 13 |
+
from app.core.exceptions import NotFoundError
|
| 14 |
+
from app.db.session import get_db
|
| 15 |
+
from app.models.prompt import CustomPrompt
|
| 16 |
+
from app.schemas.prompt import (
|
| 17 |
+
PromptCreate,
|
| 18 |
+
PromptListResponse,
|
| 19 |
+
PromptResponse,
|
| 20 |
+
PromptUpdate,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("", response_model=List[PromptResponse])
|
| 29 |
+
async def list_prompts(
|
| 30 |
+
category: Optional[str] = None,
|
| 31 |
+
user_id: str = Depends(get_current_user_id),
|
| 32 |
+
db: Session = Depends(get_db),
|
| 33 |
+
):
|
| 34 |
+
"""
|
| 35 |
+
List all custom prompts for the current user.
|
| 36 |
+
"""
|
| 37 |
+
query = db.query(CustomPrompt).filter(CustomPrompt.user_id == user_id)
|
| 38 |
+
|
| 39 |
+
if category:
|
| 40 |
+
query = query.filter(CustomPrompt.category == category)
|
| 41 |
+
|
| 42 |
+
prompts = query.order_by(CustomPrompt.created_at.desc()).all()
|
| 43 |
+
|
| 44 |
+
return [
|
| 45 |
+
PromptResponse(
|
| 46 |
+
id=str(p.id),
|
| 47 |
+
name=p.name,
|
| 48 |
+
prompt=p.prompt_text,
|
| 49 |
+
description=p.description,
|
| 50 |
+
category=p.category,
|
| 51 |
+
is_default=p.is_default,
|
| 52 |
+
created_at=p.created_at,
|
| 53 |
+
updated_at=p.updated_at,
|
| 54 |
+
)
|
| 55 |
+
for p in prompts
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("", response_model=PromptResponse, status_code=201)
|
| 60 |
+
async def create_prompt(
|
| 61 |
+
request: PromptCreate,
|
| 62 |
+
user=Depends(get_current_user),
|
| 63 |
+
db: Session = Depends(get_db),
|
| 64 |
+
):
|
| 65 |
+
"""
|
| 66 |
+
Create a new custom prompt.
|
| 67 |
+
"""
|
| 68 |
+
prompt = CustomPrompt(
|
| 69 |
+
user_id=user.clerk_user_id,
|
| 70 |
+
name=request.name,
|
| 71 |
+
prompt_text=request.prompt,
|
| 72 |
+
description=request.description,
|
| 73 |
+
category=request.category,
|
| 74 |
+
is_default=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
db.add(prompt)
|
| 78 |
+
db.commit()
|
| 79 |
+
db.refresh(prompt)
|
| 80 |
+
|
| 81 |
+
logger.info(f"Prompt created: {prompt.id} by user {user.clerk_user_id[:12]}")
|
| 82 |
+
|
| 83 |
+
return PromptResponse(
|
| 84 |
+
id=str(prompt.id),
|
| 85 |
+
name=prompt.name,
|
| 86 |
+
prompt=prompt.prompt_text,
|
| 87 |
+
description=prompt.description,
|
| 88 |
+
category=prompt.category,
|
| 89 |
+
is_default=prompt.is_default,
|
| 90 |
+
created_at=prompt.created_at,
|
| 91 |
+
updated_at=prompt.updated_at,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.get("/{prompt_id}", response_model=PromptResponse)
|
| 96 |
+
async def get_prompt(
|
| 97 |
+
prompt_id: str,
|
| 98 |
+
user_id: str = Depends(get_current_user_id),
|
| 99 |
+
db: Session = Depends(get_db),
|
| 100 |
+
):
|
| 101 |
+
"""
|
| 102 |
+
Get a specific prompt by ID.
|
| 103 |
+
"""
|
| 104 |
+
prompt = (
|
| 105 |
+
db.query(CustomPrompt)
|
| 106 |
+
.filter(
|
| 107 |
+
CustomPrompt.id == prompt_id,
|
| 108 |
+
CustomPrompt.user_id == user_id,
|
| 109 |
+
)
|
| 110 |
+
.first()
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if not prompt:
|
| 114 |
+
raise NotFoundError("Prompt", prompt_id)
|
| 115 |
+
|
| 116 |
+
return PromptResponse(
|
| 117 |
+
id=str(prompt.id),
|
| 118 |
+
name=prompt.name,
|
| 119 |
+
prompt=prompt.prompt_text,
|
| 120 |
+
description=prompt.description,
|
| 121 |
+
category=prompt.category,
|
| 122 |
+
is_default=prompt.is_default,
|
| 123 |
+
created_at=prompt.created_at,
|
| 124 |
+
updated_at=prompt.updated_at,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.put("/{prompt_id}", response_model=PromptResponse)
|
| 129 |
+
async def update_prompt(
|
| 130 |
+
prompt_id: str,
|
| 131 |
+
request: PromptUpdate,
|
| 132 |
+
user_id: str = Depends(get_current_user_id),
|
| 133 |
+
db: Session = Depends(get_db),
|
| 134 |
+
):
|
| 135 |
+
"""
|
| 136 |
+
Update a custom prompt.
|
| 137 |
+
"""
|
| 138 |
+
prompt = (
|
| 139 |
+
db.query(CustomPrompt)
|
| 140 |
+
.filter(
|
| 141 |
+
CustomPrompt.id == prompt_id,
|
| 142 |
+
CustomPrompt.user_id == user_id,
|
| 143 |
+
)
|
| 144 |
+
.first()
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if not prompt:
|
| 148 |
+
raise NotFoundError("Prompt", prompt_id)
|
| 149 |
+
|
| 150 |
+
if request.name is not None:
|
| 151 |
+
prompt.name = request.name
|
| 152 |
+
if request.prompt is not None:
|
| 153 |
+
prompt.prompt_text = request.prompt
|
| 154 |
+
if request.description is not None:
|
| 155 |
+
prompt.description = request.description
|
| 156 |
+
if request.category is not None:
|
| 157 |
+
prompt.category = request.category
|
| 158 |
+
|
| 159 |
+
db.commit()
|
| 160 |
+
db.refresh(prompt)
|
| 161 |
+
|
| 162 |
+
return PromptResponse(
|
| 163 |
+
id=str(prompt.id),
|
| 164 |
+
name=prompt.name,
|
| 165 |
+
prompt=prompt.prompt_text,
|
| 166 |
+
description=prompt.description,
|
| 167 |
+
category=prompt.category,
|
| 168 |
+
is_default=prompt.is_default,
|
| 169 |
+
created_at=prompt.created_at,
|
| 170 |
+
updated_at=prompt.updated_at,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.delete("/{prompt_id}")
|
| 175 |
+
async def delete_prompt(
|
| 176 |
+
prompt_id: str,
|
| 177 |
+
user_id: str = Depends(get_current_user_id),
|
| 178 |
+
db: Session = Depends(get_db),
|
| 179 |
+
):
|
| 180 |
+
"""
|
| 181 |
+
Delete a custom prompt.
|
| 182 |
+
"""
|
| 183 |
+
prompt = (
|
| 184 |
+
db.query(CustomPrompt)
|
| 185 |
+
.filter(
|
| 186 |
+
CustomPrompt.id == prompt_id,
|
| 187 |
+
CustomPrompt.user_id == user_id,
|
| 188 |
+
)
|
| 189 |
+
.first()
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
if not prompt:
|
| 193 |
+
raise NotFoundError("Prompt", prompt_id)
|
| 194 |
+
|
| 195 |
+
db.delete(prompt)
|
| 196 |
+
db.commit()
|
| 197 |
+
|
| 198 |
+
logger.info(f"Prompt deleted: {prompt_id}")
|
| 199 |
+
|
| 200 |
+
return {"success": True, "message": "Prompt deleted successfully"}
|
app/api/v1/router.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API v1 Router
|
| 3 |
+
Combines all endpoint routers into single API router
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter
|
| 7 |
+
|
| 8 |
+
from app.api.v1 import (
|
| 9 |
+
analytics,
|
| 10 |
+
chat,
|
| 11 |
+
chat_stream,
|
| 12 |
+
compliance,
|
| 13 |
+
documents,
|
| 14 |
+
health,
|
| 15 |
+
jobs,
|
| 16 |
+
prompts,
|
| 17 |
+
search,
|
| 18 |
+
user,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
api_router = APIRouter()
|
| 22 |
+
|
| 23 |
+
# Include all routers
|
| 24 |
+
api_router.include_router(health.router, tags=["Health"])
|
| 25 |
+
|
| 26 |
+
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])
|
| 27 |
+
|
| 28 |
+
api_router.include_router(chat.router, prefix="/chat", tags=["Chat"])
|
| 29 |
+
|
| 30 |
+
api_router.include_router(chat_stream.router, prefix="/chat", tags=["Chat"])
|
| 31 |
+
|
| 32 |
+
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
| 33 |
+
|
| 34 |
+
api_router.include_router(jobs.router, prefix="/jobs", tags=["Jobs"])
|
| 35 |
+
|
| 36 |
+
api_router.include_router(prompts.router, prefix="/prompts", tags=["Prompts"])
|
| 37 |
+
|
| 38 |
+
api_router.include_router(user.router, prefix="/user", tags=["User"])
|
| 39 |
+
|
| 40 |
+
api_router.include_router(search.router, prefix="/search", tags=["Search"])
|
| 41 |
+
|
| 42 |
+
api_router.include_router(compliance.router, prefix="/compliance", tags=["Compliance"])
|
app/api/v1/search.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Semantic Search API Endpoint
|
| 3 |
+
Natural language search across all user documents.
|
| 4 |
+
|
| 5 |
+
Production retrieval pipeline:
|
| 6 |
+
1. Embed query via Gemini text-embedding-004
|
| 7 |
+
2. Hybrid search: pgvector cosine + pg_trgm BM25 via RRF
|
| 8 |
+
3. Cross-encoder reranking for precise relevance
|
| 9 |
+
4. Results grouped by document with page-level provenance
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
from typing import List, Optional
|
| 15 |
+
|
| 16 |
+
import google.generativeai as genai
|
| 17 |
+
from fastapi import APIRouter, Depends
|
| 18 |
+
from fastapi import Query as FastAPIQuery
|
| 19 |
+
from sqlalchemy.orm import Session
|
| 20 |
+
|
| 21 |
+
from app.api.deps import get_current_user_id
|
| 22 |
+
from app.config import settings
|
| 23 |
+
from app.db.session import get_db
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
router = APIRouter()
|
| 28 |
+
|
| 29 |
+
# Configure Gemini for embeddings
|
| 30 |
+
genai.configure(api_key=settings.GEMINI_API_KEY)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def _get_query_embedding(text_input: str) -> List[float]:
|
| 34 |
+
"""Generate query embedding using Gemini text-embedding-004."""
|
| 35 |
+
try:
|
| 36 |
+
result = await asyncio.to_thread(
|
| 37 |
+
genai.embed_content,
|
| 38 |
+
model=settings.EMBEDDING_MODEL,
|
| 39 |
+
content=text_input,
|
| 40 |
+
task_type="retrieval_query",
|
| 41 |
+
)
|
| 42 |
+
return result["embedding"]
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Query embedding failed: {e}")
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("")
|
| 49 |
+
async def semantic_search(
|
| 50 |
+
q: str = FastAPIQuery(
|
| 51 |
+
..., min_length=2, description="Natural language search query"
|
| 52 |
+
),
|
| 53 |
+
limit: int = FastAPIQuery(default=20, ge=1, le=50),
|
| 54 |
+
category: Optional[str] = FastAPIQuery(default=None),
|
| 55 |
+
user_id: str = Depends(get_current_user_id),
|
| 56 |
+
db: Session = Depends(get_db),
|
| 57 |
+
):
|
| 58 |
+
"""
|
| 59 |
+
Semantic search across all user documents.
|
| 60 |
+
|
| 61 |
+
Production pipeline:
|
| 62 |
+
1. Hybrid search (pgvector + pg_trgm via RRF)
|
| 63 |
+
2. Cross-encoder reranking
|
| 64 |
+
3. Results with page-level provenance
|
| 65 |
+
|
| 66 |
+
Example queries:
|
| 67 |
+
- "ventilation rules for underground mines"
|
| 68 |
+
- "30 CFR 75.323 methane requirements"
|
| 69 |
+
- "equipment maintenance schedule for Caterpillar D11"
|
| 70 |
+
"""
|
| 71 |
+
if not q.strip():
|
| 72 |
+
return {"query": q, "results": [], "total": 0}
|
| 73 |
+
|
| 74 |
+
# Generate query embedding
|
| 75 |
+
embedding = await _get_query_embedding(q.strip())
|
| 76 |
+
if not embedding:
|
| 77 |
+
return {
|
| 78 |
+
"query": q,
|
| 79 |
+
"results": [],
|
| 80 |
+
"total": 0,
|
| 81 |
+
"error": "Could not generate embedding for query",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Step 1: Hybrid search (over-fetch for reranking)
|
| 85 |
+
from app.services.hybrid_search import hybrid_search
|
| 86 |
+
|
| 87 |
+
candidates = await hybrid_search(
|
| 88 |
+
query_text=q.strip(),
|
| 89 |
+
query_embedding=embedding,
|
| 90 |
+
db=db,
|
| 91 |
+
user_id=user_id,
|
| 92 |
+
top_k=settings.RERANK_OVER_FETCH,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Step 2: Rerank
|
| 96 |
+
if settings.ENABLE_RERANKING and len(candidates) > limit:
|
| 97 |
+
from app.services.reranker import rerank
|
| 98 |
+
|
| 99 |
+
candidates = rerank(query=q.strip(), chunks=candidates, top_k=limit)
|
| 100 |
+
else:
|
| 101 |
+
candidates = candidates[:limit]
|
| 102 |
+
|
| 103 |
+
# Format results
|
| 104 |
+
results = []
|
| 105 |
+
for row in candidates:
|
| 106 |
+
pages = row.get("page_numbers", [])
|
| 107 |
+
page_str = (
|
| 108 |
+
f"Pages {pages[0]}\u2013{pages[-1]}"
|
| 109 |
+
if len(pages) > 1
|
| 110 |
+
else f"Page {pages[0]}" if pages else "Unknown page"
|
| 111 |
+
)
|
| 112 |
+
results.append(
|
| 113 |
+
{
|
| 114 |
+
"chunk_id": row["id"],
|
| 115 |
+
"document_id": row["document_id"],
|
| 116 |
+
"document_title": row["document_title"],
|
| 117 |
+
"file_name": row["file_name"],
|
| 118 |
+
"chunk_text": row["text"],
|
| 119 |
+
"section_title": row.get("section_title"),
|
| 120 |
+
"page_numbers": pages,
|
| 121 |
+
"page_label": page_str,
|
| 122 |
+
"relevance_score": round(
|
| 123 |
+
row.get("rerank_score", row.get("score", 0.0)), 4
|
| 124 |
+
),
|
| 125 |
+
"relevance_percent": round(
|
| 126 |
+
row.get("rerank_score", row.get("score", 0.0)) * 100, 1
|
| 127 |
+
),
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"query": q,
|
| 133 |
+
"results": results,
|
| 134 |
+
"total": len(results),
|
| 135 |
+
"filters_applied": {
|
| 136 |
+
"category": category,
|
| 137 |
+
},
|
| 138 |
+
}
|
app/api/v1/user.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Profile API Endpoints
|
| 3 |
+
User profile management endpoints
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.api.deps import get_current_user, get_current_user_id
|
| 14 |
+
from app.db.session import get_db
|
| 15 |
+
from app.models.user import User
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class UserProfileResponse(BaseModel):
|
| 23 |
+
"""User profile response"""
|
| 24 |
+
|
| 25 |
+
clerk_user_id: str
|
| 26 |
+
email: Optional[str] = None
|
| 27 |
+
full_name: Optional[str] = None
|
| 28 |
+
avatar_url: Optional[str] = None
|
| 29 |
+
company_name: Optional[str] = None
|
| 30 |
+
company_role: Optional[str] = None
|
| 31 |
+
industry_focus: Optional[List[str]] = None
|
| 32 |
+
mine_sites: Optional[List[str]] = None
|
| 33 |
+
is_active: bool = True
|
| 34 |
+
|
| 35 |
+
class Config:
|
| 36 |
+
from_attributes = True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class UserProfileUpdate(BaseModel):
|
| 40 |
+
"""User profile update request"""
|
| 41 |
+
|
| 42 |
+
full_name: Optional[str] = None
|
| 43 |
+
company_name: Optional[str] = None
|
| 44 |
+
company_role: Optional[str] = None
|
| 45 |
+
industry_focus: Optional[List[str]] = None
|
| 46 |
+
mine_sites: Optional[List[str]] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/profile", response_model=UserProfileResponse)
|
| 50 |
+
async def get_user_profile(
|
| 51 |
+
user: User = Depends(get_current_user),
|
| 52 |
+
):
|
| 53 |
+
"""
|
| 54 |
+
Get the current user's profile.
|
| 55 |
+
Creates the user record on first access if it doesn't exist.
|
| 56 |
+
"""
|
| 57 |
+
return UserProfileResponse(
|
| 58 |
+
clerk_user_id=user.clerk_user_id,
|
| 59 |
+
email=user.email,
|
| 60 |
+
full_name=user.full_name,
|
| 61 |
+
avatar_url=user.avatar_url,
|
| 62 |
+
company_name=user.company_name,
|
| 63 |
+
company_role=user.company_role,
|
| 64 |
+
industry_focus=user.industry_focus,
|
| 65 |
+
mine_sites=user.mine_sites,
|
| 66 |
+
is_active=user.is_active,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.put("/profile", response_model=UserProfileResponse)
|
| 71 |
+
async def update_user_profile(
|
| 72 |
+
request: UserProfileUpdate,
|
| 73 |
+
user: User = Depends(get_current_user),
|
| 74 |
+
db: Session = Depends(get_db),
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Update the current user's profile.
|
| 78 |
+
"""
|
| 79 |
+
if request.full_name is not None:
|
| 80 |
+
user.full_name = request.full_name
|
| 81 |
+
if request.company_name is not None:
|
| 82 |
+
user.company_name = request.company_name
|
| 83 |
+
if request.company_role is not None:
|
| 84 |
+
user.company_role = request.company_role
|
| 85 |
+
if request.industry_focus is not None:
|
| 86 |
+
user.industry_focus = request.industry_focus
|
| 87 |
+
if request.mine_sites is not None:
|
| 88 |
+
user.mine_sites = request.mine_sites
|
| 89 |
+
|
| 90 |
+
db.commit()
|
| 91 |
+
db.refresh(user)
|
| 92 |
+
|
| 93 |
+
logger.info(f"User profile updated: {user.clerk_user_id[:12]}")
|
| 94 |
+
|
| 95 |
+
return UserProfileResponse(
|
| 96 |
+
clerk_user_id=user.clerk_user_id,
|
| 97 |
+
email=user.email,
|
| 98 |
+
full_name=user.full_name,
|
| 99 |
+
avatar_url=user.avatar_url,
|
| 100 |
+
company_name=user.company_name,
|
| 101 |
+
company_role=user.company_role,
|
| 102 |
+
industry_focus=user.industry_focus,
|
| 103 |
+
mine_sites=user.mine_sites,
|
| 104 |
+
is_active=user.is_active,
|
| 105 |
+
)
|
app/config.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application Configuration
|
| 3 |
+
Centralized settings management using Pydantic Settings
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import Field
|
| 11 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings(BaseSettings):
|
| 15 |
+
"""Application settings loaded from environment variables"""
|
| 16 |
+
|
| 17 |
+
# Application
|
| 18 |
+
APP_NAME: str = "MiningNiti"
|
| 19 |
+
APP_VERSION: str = "2.0.0"
|
| 20 |
+
DEBUG: bool = Field(default=False)
|
| 21 |
+
ENVIRONMENT: str = Field(default="development")
|
| 22 |
+
|
| 23 |
+
# API
|
| 24 |
+
API_V1_PREFIX: str = "/api/v1"
|
| 25 |
+
CORS_ORIGINS: List[str] = Field(
|
| 26 |
+
default=["http://localhost:3000", "https://*.vercel.app"]
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Database
|
| 30 |
+
DATABASE_URL: str = Field(..., description="PostgreSQL connection string")
|
| 31 |
+
DB_POOL_SIZE: int = Field(default=5)
|
| 32 |
+
DB_MAX_OVERFLOW: int = Field(default=10)
|
| 33 |
+
|
| 34 |
+
# Redis
|
| 35 |
+
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
| 36 |
+
|
| 37 |
+
# AI/ML - Multi-Provider Setup
|
| 38 |
+
GEMINI_API_KEY: str = Field(..., description="Google Gemini API Key")
|
| 39 |
+
GROQ_API_KEY: str = Field(
|
| 40 |
+
..., description="Groq API Key for Classifier & Entity Extractors"
|
| 41 |
+
)
|
| 42 |
+
MISTRAL_API_KEY: str = Field(..., description="Mistral API Key for Safety Analyzer")
|
| 43 |
+
CEREBRAS_API_KEY: str = Field(default="", description="Cerebras API Key")
|
| 44 |
+
|
| 45 |
+
GEMINI_MODEL: str = Field(default="gemini-1.5-flash")
|
| 46 |
+
EMBEDDING_MODEL: str = Field(default="models/gemini-embedding-001")
|
| 47 |
+
|
| 48 |
+
AGENT_PROVIDER_MAP: dict = {
|
| 49 |
+
"embeddings": {"provider": "gemini", "model": "text-embedding-004"},
|
| 50 |
+
"chat_service": {"provider": "gemini", "model": "gemini-1.5-flash"},
|
| 51 |
+
"summarizer_agent": {"provider": "gemini", "model": "gemini-1.5-flash"},
|
| 52 |
+
"classifier_agent": {"provider": "groq", "model": "llama-3.3-70b-versatile"},
|
| 53 |
+
"entity_extractor": {"provider": "cerebras", "model": "llama-4-scout"},
|
| 54 |
+
"safety_analyzer": {"provider": "mistral", "model": "magistral-small-latest"},
|
| 55 |
+
"fallback": {"provider": "openrouter", "model": "deepseek/deepseek-r1:free"},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Authentication - Clerk
|
| 59 |
+
CLERK_JWKS_URL: str = Field(..., description="Clerk JWKS URL for JWT verification")
|
| 60 |
+
|
| 61 |
+
# Document Processing
|
| 62 |
+
MAX_FILE_SIZE_MB: int = Field(default=50)
|
| 63 |
+
ALLOWED_FILE_TYPES: List[str] = Field(
|
| 64 |
+
default=[
|
| 65 |
+
"application/pdf",
|
| 66 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 67 |
+
"text/plain",
|
| 68 |
+
]
|
| 69 |
+
)
|
| 70 |
+
CHUNK_SIZE: int = Field(default=1000)
|
| 71 |
+
CHUNK_OVERLAP: int = Field(default=200)
|
| 72 |
+
|
| 73 |
+
# Mining AI Settings
|
| 74 |
+
SAFETY_SCORE_THRESHOLD: float = Field(default=70.0)
|
| 75 |
+
MAX_EMBEDDINGS_PER_QUERY: int = Field(default=5)
|
| 76 |
+
|
| 77 |
+
# RAG Pipeline — Production Retrieval
|
| 78 |
+
RERANK_MODEL: str = Field(
|
| 79 |
+
default="cross-encoder/ms-marco-MiniLM-L-6-v2",
|
| 80 |
+
description="Cross-encoder model for reranking retrieved chunks",
|
| 81 |
+
)
|
| 82 |
+
RERANK_OVER_FETCH: int = Field(
|
| 83 |
+
default=20,
|
| 84 |
+
description="How many chunks to fetch from vector+BM25 before reranking",
|
| 85 |
+
)
|
| 86 |
+
RERANK_TOP_K: int = Field(
|
| 87 |
+
default=5,
|
| 88 |
+
description="Final number of chunks after reranking",
|
| 89 |
+
)
|
| 90 |
+
SIMILARITY_THRESHOLD: float = Field(
|
| 91 |
+
default=0.25,
|
| 92 |
+
description="Minimum cosine similarity to include a chunk (0-1)",
|
| 93 |
+
)
|
| 94 |
+
ENABLE_HYBRID_SEARCH: bool = Field(
|
| 95 |
+
default=True,
|
| 96 |
+
description="Combine vector search with pg_trgm BM25 via RRF",
|
| 97 |
+
)
|
| 98 |
+
ENABLE_RERANKING: bool = Field(
|
| 99 |
+
default=True,
|
| 100 |
+
description="Apply cross-encoder reranking after retrieval",
|
| 101 |
+
)
|
| 102 |
+
RRF_K: int = Field(
|
| 103 |
+
default=60,
|
| 104 |
+
description="Reciprocal Rank Fusion constant (higher = less rank influence)",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# SSL
|
| 108 |
+
SSL_CERT_PATH: Optional[str] = Field(default=None)
|
| 109 |
+
|
| 110 |
+
model_config = SettingsConfigDict(
|
| 111 |
+
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@lru_cache()
|
| 116 |
+
def get_settings() -> Settings:
|
| 117 |
+
"""
|
| 118 |
+
Get cached settings instance.
|
| 119 |
+
Uses lru_cache for performance - settings are loaded once.
|
| 120 |
+
"""
|
| 121 |
+
return Settings()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Convenience export
|
| 125 |
+
settings = get_settings()
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core module - Security, Exceptions, Utilities
|
| 3 |
+
"""
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom Exception Classes
|
| 3 |
+
Enterprise-grade error handling with proper HTTP status codes
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Any, Dict, Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import HTTPException, status
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MiningNitiException(Exception):
|
| 12 |
+
"""Base exception for all MiningNiti errors"""
|
| 13 |
+
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
message: str,
|
| 17 |
+
code: str = "INTERNAL_ERROR",
|
| 18 |
+
details: Optional[Dict[str, Any]] = None,
|
| 19 |
+
):
|
| 20 |
+
self.message = message
|
| 21 |
+
self.code = code
|
| 22 |
+
self.details = details or {}
|
| 23 |
+
super().__init__(self.message)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AuthenticationError(HTTPException):
|
| 27 |
+
"""Raised when authentication fails"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, detail: str = "Authentication failed"):
|
| 30 |
+
import logging
|
| 31 |
+
|
| 32 |
+
logging.getLogger("app.core.exceptions").warning(
|
| 33 |
+
f"AuthenticationError raised: {detail}"
|
| 34 |
+
)
|
| 35 |
+
super().__init__(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail=detail,
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AuthorizationError(HTTPException):
|
| 43 |
+
"""Raised when user lacks permission"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, detail: str = "Permission denied"):
|
| 46 |
+
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class NotFoundError(HTTPException):
|
| 50 |
+
"""Raised when resource is not found"""
|
| 51 |
+
|
| 52 |
+
def __init__(self, resource: str = "Resource", resource_id: str = ""):
|
| 53 |
+
detail = f"{resource} not found"
|
| 54 |
+
if resource_id:
|
| 55 |
+
detail = f"{resource} with id '{resource_id}' not found"
|
| 56 |
+
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ValidationError(HTTPException):
|
| 60 |
+
"""Raised when request validation fails"""
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self, detail: str = "Validation failed", errors: Optional[list] = None
|
| 64 |
+
):
|
| 65 |
+
super().__init__(
|
| 66 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 67 |
+
detail={"message": detail, "errors": errors or []},
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class DocumentProcessingError(MiningNitiException):
|
| 72 |
+
"""Raised when document processing fails"""
|
| 73 |
+
|
| 74 |
+
def __init__(self, message: str, document_id: Optional[str] = None):
|
| 75 |
+
super().__init__(
|
| 76 |
+
message=message,
|
| 77 |
+
code="DOCUMENT_PROCESSING_ERROR",
|
| 78 |
+
details={"document_id": document_id} if document_id else {},
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class AIServiceError(MiningNitiException):
|
| 83 |
+
"""Raised when AI service (Gemini) fails"""
|
| 84 |
+
|
| 85 |
+
def __init__(self, message: str, service: str = "gemini"):
|
| 86 |
+
super().__init__(
|
| 87 |
+
message=message, code="AI_SERVICE_ERROR", details={"service": service}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class RateLimitError(HTTPException):
|
| 92 |
+
"""Raised when rate limit is exceeded"""
|
| 93 |
+
|
| 94 |
+
def __init__(self, retry_after: int = 60):
|
| 95 |
+
super().__init__(
|
| 96 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 97 |
+
detail=f"Rate limit exceeded. Retry after {retry_after} seconds.",
|
| 98 |
+
headers={"Retry-After": str(retry_after)},
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class JobNotFoundError(NotFoundError):
|
| 103 |
+
"""Raised when background job is not found"""
|
| 104 |
+
|
| 105 |
+
def __init__(self, job_id: str):
|
| 106 |
+
super().__init__(resource="Job", resource_id=job_id)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class SafetyViolationError(MiningNitiException):
|
| 110 |
+
"""Raised when safety compliance check fails critically"""
|
| 111 |
+
|
| 112 |
+
def __init__(self, message: str, violations: list):
|
| 113 |
+
super().__init__(
|
| 114 |
+
message=message, code="SAFETY_VIOLATION", details={"violations": violations}
|
| 115 |
+
)
|
app/core/security.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Security Module
|
| 3 |
+
JWT verification, authentication, and authorization utilities
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
from jose import JWTError, jwk, jwt
|
| 13 |
+
from jose.exceptions import ExpiredSignatureError
|
| 14 |
+
|
| 15 |
+
from app.config import settings
|
| 16 |
+
from app.core.exceptions import AuthenticationError
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class JWKSClient:
|
| 22 |
+
"""
|
| 23 |
+
JWKS (JSON Web Key Set) client for Clerk JWT verification.
|
| 24 |
+
Caches keys to avoid repeated network requests.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, jwks_url: str):
|
| 28 |
+
self.jwks_url = jwks_url
|
| 29 |
+
self._keys: Dict[str, Any] = {}
|
| 30 |
+
self._last_fetch: Optional[datetime] = None
|
| 31 |
+
self._cache_duration = timedelta(hours=1)
|
| 32 |
+
|
| 33 |
+
async def get_signing_key(self, kid: str) -> Optional[Dict[str, Any]]:
|
| 34 |
+
"""Get signing key by key ID (kid)"""
|
| 35 |
+
await self._refresh_keys_if_needed()
|
| 36 |
+
return self._keys.get(kid)
|
| 37 |
+
|
| 38 |
+
async def _refresh_keys_if_needed(self, force: bool = False):
|
| 39 |
+
"""Refresh keys if cache is stale or force is True"""
|
| 40 |
+
now = datetime.utcnow()
|
| 41 |
+
|
| 42 |
+
if (
|
| 43 |
+
not force
|
| 44 |
+
and self._last_fetch
|
| 45 |
+
and (now - self._last_fetch) < self._cache_duration
|
| 46 |
+
):
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
async with httpx.AsyncClient() as client:
|
| 51 |
+
response = await client.get(self.jwks_url, timeout=10.0)
|
| 52 |
+
response.raise_for_status()
|
| 53 |
+
jwks_data = response.json()
|
| 54 |
+
|
| 55 |
+
self._keys = {key["kid"]: key for key in jwks_data.get("keys", [])}
|
| 56 |
+
self._last_fetch = now
|
| 57 |
+
logger.info(f"Refreshed JWKS keys: {len(self._keys)} keys loaded")
|
| 58 |
+
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Failed to fetch JWKS: {e}")
|
| 61 |
+
if not self._keys:
|
| 62 |
+
raise AuthenticationError("Unable to verify authentication")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Global JWKS client instance
|
| 66 |
+
_jwks_client: Optional[JWKSClient] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_jwks_client() -> JWKSClient:
|
| 70 |
+
"""Get or create JWKS client singleton"""
|
| 71 |
+
global _jwks_client
|
| 72 |
+
if _jwks_client is None:
|
| 73 |
+
_jwks_client = JWKSClient(settings.CLERK_JWKS_URL)
|
| 74 |
+
return _jwks_client
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def verify_jwt_token(token: str) -> Dict[str, Any]:
|
| 78 |
+
"""
|
| 79 |
+
Verify JWT token from Clerk.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
token: JWT token string
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Decoded token payload with user information
|
| 86 |
+
|
| 87 |
+
Raises:
|
| 88 |
+
AuthenticationError: If token is invalid or expired
|
| 89 |
+
"""
|
| 90 |
+
try:
|
| 91 |
+
# Decode header to get key ID
|
| 92 |
+
unverified_header = jwt.get_unverified_header(token)
|
| 93 |
+
kid = unverified_header.get("kid")
|
| 94 |
+
|
| 95 |
+
if not kid:
|
| 96 |
+
raise AuthenticationError("Invalid token: missing key ID")
|
| 97 |
+
|
| 98 |
+
# Get signing key
|
| 99 |
+
jwks_client = get_jwks_client()
|
| 100 |
+
signing_key = await jwks_client.get_signing_key(kid)
|
| 101 |
+
|
| 102 |
+
if not signing_key:
|
| 103 |
+
# Force refresh and try again
|
| 104 |
+
await jwks_client._refresh_keys_if_needed(force=True)
|
| 105 |
+
signing_key = await jwks_client.get_signing_key(kid)
|
| 106 |
+
|
| 107 |
+
if not signing_key:
|
| 108 |
+
raise AuthenticationError("Invalid token: unknown signing key")
|
| 109 |
+
|
| 110 |
+
# Verify and decode token
|
| 111 |
+
payload = jwt.decode(
|
| 112 |
+
token,
|
| 113 |
+
signing_key,
|
| 114 |
+
algorithms=["RS256"],
|
| 115 |
+
options={"verify_aud": False}, # Clerk doesn't always set audience
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return payload
|
| 119 |
+
|
| 120 |
+
except ExpiredSignatureError:
|
| 121 |
+
raise AuthenticationError("Token has expired")
|
| 122 |
+
except JWTError as e:
|
| 123 |
+
logger.warning(f"JWT verification failed: {e}")
|
| 124 |
+
raise AuthenticationError("Invalid token")
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"Authentication error: {e}")
|
| 127 |
+
raise AuthenticationError("Authentication failed")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def extract_user_id(payload: Dict[str, Any]) -> str:
|
| 131 |
+
"""
|
| 132 |
+
Extract user ID from JWT payload.
|
| 133 |
+
Clerk uses 'sub' claim for user ID.
|
| 134 |
+
"""
|
| 135 |
+
user_id = payload.get("sub")
|
| 136 |
+
if not user_id:
|
| 137 |
+
raise AuthenticationError("Invalid token: missing user ID")
|
| 138 |
+
return user_id
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def extract_user_email(payload: Dict[str, Any]) -> Optional[str]:
|
| 142 |
+
"""Extract email from JWT payload if available"""
|
| 143 |
+
# Clerk may include email in different claims
|
| 144 |
+
return (
|
| 145 |
+
payload.get("email")
|
| 146 |
+
or payload.get("primary_email")
|
| 147 |
+
or payload.get("email_addresses", [{}])[0].get("email_address")
|
| 148 |
+
)
|
app/db/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database module - Session management and migrations
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from app.db.session import Base, SessionLocal, engine, get_db
|
| 6 |
+
|
| 7 |
+
__all__ = ["engine", "SessionLocal", "get_db", "Base"]
|
app/db/session.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database Session Management
|
| 3 |
+
SQLAlchemy engine and session configuration with connection pooling
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from contextlib import contextmanager
|
| 8 |
+
from typing import Generator
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import create_engine, event, text
|
| 11 |
+
from sqlalchemy.orm import Session, sessionmaker
|
| 12 |
+
from sqlalchemy.pool import QueuePool
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
|
| 16 |
+
# Use the single canonical Base so all models share the same metadata
|
| 17 |
+
from app.models.base import Base # noqa: F401 - re-exported for convenience
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
# Configure engine with connection pooling
|
| 22 |
+
engine_args = {
|
| 23 |
+
"pool_size": settings.DB_POOL_SIZE,
|
| 24 |
+
"max_overflow": settings.DB_MAX_OVERFLOW,
|
| 25 |
+
"pool_pre_ping": True, # Verify connections before use
|
| 26 |
+
"pool_recycle": 3600, # Recycle connections after 1 hour
|
| 27 |
+
"echo": False, # Suppress excessive SQL query logging
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Add SSL config if certificate path provided
|
| 31 |
+
connect_args = {}
|
| 32 |
+
if settings.SSL_CERT_PATH:
|
| 33 |
+
connect_args["sslmode"] = "require"
|
| 34 |
+
connect_args["sslrootcert"] = settings.SSL_CERT_PATH
|
| 35 |
+
|
| 36 |
+
engine = create_engine(
|
| 37 |
+
settings.DATABASE_URL, poolclass=QueuePool, connect_args=connect_args, **engine_args
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Session factory
|
| 41 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# Connection event listeners for debugging
|
| 45 |
+
@event.listens_for(engine, "connect")
|
| 46 |
+
def on_connect(dbapi_conn, connection_record):
|
| 47 |
+
logger.debug("Database connection established")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@event.listens_for(engine, "checkout")
|
| 51 |
+
def on_checkout(dbapi_conn, connection_record, connection_proxy):
|
| 52 |
+
logger.debug("Database connection checked out from pool")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_db() -> Generator[Session, None, None]:
|
| 56 |
+
"""
|
| 57 |
+
Dependency for FastAPI endpoints.
|
| 58 |
+
Yields a database session and ensures cleanup.
|
| 59 |
+
|
| 60 |
+
Usage:
|
| 61 |
+
@app.get("/items")
|
| 62 |
+
def get_items(db: Session = Depends(get_db)):
|
| 63 |
+
...
|
| 64 |
+
"""
|
| 65 |
+
db = SessionLocal()
|
| 66 |
+
try:
|
| 67 |
+
yield db
|
| 68 |
+
finally:
|
| 69 |
+
db.close()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@contextmanager
|
| 73 |
+
def get_db_context() -> Generator[Session, None, None]:
|
| 74 |
+
"""
|
| 75 |
+
Context manager for database sessions outside of FastAPI.
|
| 76 |
+
Useful for background workers and scripts.
|
| 77 |
+
|
| 78 |
+
Usage:
|
| 79 |
+
with get_db_context() as db:
|
| 80 |
+
db.query(...)
|
| 81 |
+
"""
|
| 82 |
+
db = SessionLocal()
|
| 83 |
+
try:
|
| 84 |
+
yield db
|
| 85 |
+
db.commit()
|
| 86 |
+
except Exception:
|
| 87 |
+
db.rollback()
|
| 88 |
+
raise
|
| 89 |
+
finally:
|
| 90 |
+
db.close()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def init_db():
|
| 94 |
+
"""Initialize database tables using the canonical Base from models.base"""
|
| 95 |
+
# Import all models so they register their tables with Base.metadata
|
| 96 |
+
from app.models import audit, chat, document, prompt, user # noqa: F401
|
| 97 |
+
from app.models.base import Base as ModelBase
|
| 98 |
+
|
| 99 |
+
# Ensure pgvector extension exists before creating tables that use VECTOR columns
|
| 100 |
+
with engine.connect() as conn:
|
| 101 |
+
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
| 102 |
+
conn.commit()
|
| 103 |
+
logger.info("pgvector extension ensured")
|
| 104 |
+
|
| 105 |
+
ModelBase.metadata.create_all(bind=engine)
|
| 106 |
+
logger.info("Database tables created successfully")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def check_db_connection() -> bool:
|
| 110 |
+
"""Check if database is reachable"""
|
| 111 |
+
try:
|
| 112 |
+
with engine.connect() as conn:
|
| 113 |
+
conn.execute(text("SELECT 1"))
|
| 114 |
+
return True
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Database connection failed: {e}")
|
| 117 |
+
return False
|
app/main.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MiningNiti Enterprise Backend
|
| 3 |
+
FastAPI Application Entry Point
|
| 4 |
+
|
| 5 |
+
AI-Powered Document Intelligence for the Coal Mining Industry
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, HTTPException, Request, status
|
| 13 |
+
from fastapi.exceptions import RequestValidationError
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 17 |
+
from slowapi.errors import RateLimitExceeded
|
| 18 |
+
from slowapi.middleware import SlowAPIMiddleware
|
| 19 |
+
from slowapi.util import get_remote_address
|
| 20 |
+
|
| 21 |
+
from app.api.v1 import api_router
|
| 22 |
+
from app.config import settings
|
| 23 |
+
from app.core.exceptions import MiningNitiException
|
| 24 |
+
from app.db.session import check_db_connection, init_db
|
| 25 |
+
|
| 26 |
+
# Configure logging
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.DEBUG if settings.DEBUG else logging.INFO,
|
| 29 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# Suppress SQLAlchemy's extremely verbose SQL echo in debug mode —
|
| 34 |
+
# it drowns out real application logs. Set to WARNING to only see errors.
|
| 35 |
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
| 36 |
+
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
|
| 37 |
+
logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING)
|
| 38 |
+
# Also suppress httpcore connection-level debug spam
|
| 39 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 40 |
+
logging.getLogger("httpx").setLevel(logging.INFO)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@asynccontextmanager
|
| 44 |
+
async def lifespan(app: FastAPI):
|
| 45 |
+
"""Application lifespan handler for startup and shutdown events"""
|
| 46 |
+
# Startup
|
| 47 |
+
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
| 48 |
+
logger.info(f"Environment: {settings.ENVIRONMENT}")
|
| 49 |
+
|
| 50 |
+
import asyncio
|
| 51 |
+
|
| 52 |
+
from app.services.queue import compliance_worker, document_worker
|
| 53 |
+
|
| 54 |
+
worker_task = asyncio.create_task(document_worker())
|
| 55 |
+
compliance_worker_task = asyncio.create_task(compliance_worker())
|
| 56 |
+
|
| 57 |
+
# Check database connection
|
| 58 |
+
if check_db_connection():
|
| 59 |
+
logger.info("Database connection verified")
|
| 60 |
+
# Auto-create tables on startup (idempotent)
|
| 61 |
+
try:
|
| 62 |
+
init_db()
|
| 63 |
+
logger.info("Database tables initialized")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.warning(f"Database table creation warning: {e}")
|
| 66 |
+
|
| 67 |
+
# Recovery: reset documents stuck in transient states from a previous crash/restart
|
| 68 |
+
try:
|
| 69 |
+
from app.db.session import get_db_context
|
| 70 |
+
from app.models.document import Document, DocumentStatus
|
| 71 |
+
|
| 72 |
+
with get_db_context() as db:
|
| 73 |
+
stuck_docs = (
|
| 74 |
+
db.query(Document)
|
| 75 |
+
.filter(Document.status.in_(["processing", "analyzing"]))
|
| 76 |
+
.all()
|
| 77 |
+
)
|
| 78 |
+
if stuck_docs:
|
| 79 |
+
for doc in stuck_docs:
|
| 80 |
+
doc.status = DocumentStatus.PENDING
|
| 81 |
+
doc.processing_error = "Reset after server restart"
|
| 82 |
+
db.commit()
|
| 83 |
+
logger.info(
|
| 84 |
+
f"Recovery: reset {len(stuck_docs)} stuck document(s) to PENDING"
|
| 85 |
+
)
|
| 86 |
+
else:
|
| 87 |
+
logger.info("Recovery: no stuck documents found")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning(f"Document recovery warning: {e}")
|
| 90 |
+
|
| 91 |
+
# Recovery: reset compliance audits stuck in running state
|
| 92 |
+
try:
|
| 93 |
+
from app.models.compliance import AuditStatus, ComplianceAudit
|
| 94 |
+
|
| 95 |
+
with get_db_context() as db:
|
| 96 |
+
stuck_audits = (
|
| 97 |
+
db.query(ComplianceAudit)
|
| 98 |
+
.filter(ComplianceAudit.status.in_(["running"]))
|
| 99 |
+
.all()
|
| 100 |
+
)
|
| 101 |
+
if stuck_audits:
|
| 102 |
+
for audit in stuck_audits:
|
| 103 |
+
audit.status = AuditStatus.PENDING
|
| 104 |
+
audit.processing_error = "Reset after server restart"
|
| 105 |
+
db.commit()
|
| 106 |
+
logger.info(
|
| 107 |
+
f"Recovery: reset {len(stuck_audits)} stuck audit(s) to PENDING"
|
| 108 |
+
)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.warning(f"Audit recovery warning: {e}")
|
| 111 |
+
else:
|
| 112 |
+
logger.warning("Database connection failed - some features may not work")
|
| 113 |
+
|
| 114 |
+
yield
|
| 115 |
+
|
| 116 |
+
# Shutdown
|
| 117 |
+
logger.info("Shutting down application")
|
| 118 |
+
worker_task.cancel()
|
| 119 |
+
compliance_worker_task.cancel()
|
| 120 |
+
try:
|
| 121 |
+
await worker_task
|
| 122 |
+
except asyncio.CancelledError:
|
| 123 |
+
pass
|
| 124 |
+
try:
|
| 125 |
+
await compliance_worker_task
|
| 126 |
+
except asyncio.CancelledError:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# Create FastAPI application
|
| 131 |
+
app = FastAPI(
|
| 132 |
+
title=settings.APP_NAME,
|
| 133 |
+
description="""
|
| 134 |
+
## MiningNiti - AI Document Intelligence for Mining
|
| 135 |
+
|
| 136 |
+
Enterprise-grade document processing and AI chat platform
|
| 137 |
+
specifically designed for the coal mining industry.
|
| 138 |
+
|
| 139 |
+
### Features
|
| 140 |
+
- 📄 **Smart Document Processing** - Upload PDF, DOCX, TXT with AI analysis
|
| 141 |
+
- 🤖 **Multi-Agent AI** - Classification, Safety Analysis, Entity Extraction
|
| 142 |
+
- 💬 **RAG Chat** - Context-aware conversations with document citations
|
| 143 |
+
- 📊 **Analytics Dashboard** - Safety metrics, compliance tracking
|
| 144 |
+
- 🔒 **Enterprise Security** - JWT auth, audit logging
|
| 145 |
+
|
| 146 |
+
### AI Agents
|
| 147 |
+
1. **Classifier Agent** - Categorizes mining documents
|
| 148 |
+
2. **Safety Analyzer** - Detects hazards and compliance issues
|
| 149 |
+
3. **Entity Extractor** - Extracts equipment, chemicals, regulations
|
| 150 |
+
4. **Summarizer** - Creates executive summaries
|
| 151 |
+
""",
|
| 152 |
+
version=settings.APP_VERSION,
|
| 153 |
+
docs_url="/docs",
|
| 154 |
+
redoc_url="/redoc",
|
| 155 |
+
openapi_url="/openapi.json",
|
| 156 |
+
lifespan=lifespan,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Rate Limiter
|
| 160 |
+
limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"])
|
| 161 |
+
|
| 162 |
+
# CORS Configuration
|
| 163 |
+
_EXTRA_ORIGINS = ["http://localhost:3000", "http://localhost:3001"]
|
| 164 |
+
|
| 165 |
+
app.state.limiter = limiter
|
| 166 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 167 |
+
app.add_middleware(SlowAPIMiddleware)
|
| 168 |
+
|
| 169 |
+
app.add_middleware(
|
| 170 |
+
CORSMiddleware,
|
| 171 |
+
allow_origins=settings.CORS_ORIGINS + _EXTRA_ORIGINS,
|
| 172 |
+
allow_credentials=True,
|
| 173 |
+
allow_methods=["*"],
|
| 174 |
+
allow_headers=["*"],
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# Exception Handlers
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _get_cors_headers(request: Request) -> dict:
|
| 182 |
+
"""
|
| 183 |
+
Build CORS headers to attach to error responses.
|
| 184 |
+
This is needed because FastAPI's HTTPBearer can short-circuit before
|
| 185 |
+
CORSMiddleware has a chance to add Access-Control-Allow-Origin headers,
|
| 186 |
+
causing the browser to report a CORS error instead of the real auth error.
|
| 187 |
+
"""
|
| 188 |
+
origin = request.headers.get("origin", "")
|
| 189 |
+
allowed_origins = settings.CORS_ORIGINS + _EXTRA_ORIGINS
|
| 190 |
+
if origin in allowed_origins or any(
|
| 191 |
+
origin.endswith(o.lstrip("*")) for o in allowed_origins if "*" in o
|
| 192 |
+
):
|
| 193 |
+
return {
|
| 194 |
+
"Access-Control-Allow-Origin": origin,
|
| 195 |
+
"Access-Control-Allow-Credentials": "true",
|
| 196 |
+
}
|
| 197 |
+
return {}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@app.exception_handler(HTTPException)
|
| 201 |
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
| 202 |
+
"""Handle HTTP exceptions with CORS headers so auth failures are visible to the browser"""
|
| 203 |
+
headers = {**(exc.headers or {}), **_get_cors_headers(request)}
|
| 204 |
+
return JSONResponse(
|
| 205 |
+
status_code=exc.status_code,
|
| 206 |
+
content={"detail": exc.detail},
|
| 207 |
+
headers=headers,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@app.exception_handler(MiningNitiException)
|
| 212 |
+
async def miningniti_exception_handler(request: Request, exc: MiningNitiException):
|
| 213 |
+
"""Handle custom application exceptions"""
|
| 214 |
+
return JSONResponse(
|
| 215 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 216 |
+
content={
|
| 217 |
+
"error": exc.message,
|
| 218 |
+
"code": exc.code,
|
| 219 |
+
"details": exc.details,
|
| 220 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 221 |
+
},
|
| 222 |
+
headers=_get_cors_headers(request),
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.exception_handler(RequestValidationError)
|
| 227 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 228 |
+
"""Handle Pydantic validation errors"""
|
| 229 |
+
return JSONResponse(
|
| 230 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 231 |
+
content={
|
| 232 |
+
"error": "Validation failed",
|
| 233 |
+
"code": "VALIDATION_ERROR",
|
| 234 |
+
"details": exc.errors(),
|
| 235 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 236 |
+
},
|
| 237 |
+
headers=_get_cors_headers(request),
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@app.exception_handler(Exception)
|
| 242 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 243 |
+
"""Global exception handler for unhandled errors"""
|
| 244 |
+
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
| 245 |
+
return JSONResponse(
|
| 246 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 247 |
+
content={
|
| 248 |
+
"error": "Internal server error",
|
| 249 |
+
"code": "INTERNAL_SERVER_ERROR",
|
| 250 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 251 |
+
},
|
| 252 |
+
headers=_get_cors_headers(request),
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# Include API router
|
| 257 |
+
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# Root endpoint (without /api/v1 prefix for health checks)
|
| 261 |
+
@app.get("/", tags=["Root"])
|
| 262 |
+
async def root():
|
| 263 |
+
"""Root endpoint - application info"""
|
| 264 |
+
return {
|
| 265 |
+
"name": settings.APP_NAME,
|
| 266 |
+
"version": settings.APP_VERSION,
|
| 267 |
+
"description": "AI Document Intelligence for Mining Industry",
|
| 268 |
+
"docs": "/docs",
|
| 269 |
+
"health": f"{settings.API_V1_PREFIX}/health",
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@app.get("/health", tags=["Root"])
|
| 274 |
+
async def health():
|
| 275 |
+
"""Quick health check for load balancers"""
|
| 276 |
+
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# Run with: uvicorn app.main:app --reload
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
import uvicorn
|
| 282 |
+
|
| 283 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG)
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database Models
|
| 3 |
+
SQLAlchemy ORM models for the MiningNiti platform
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.models.audit import AuditAction, AuditLog
|
| 7 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 8 |
+
from app.models.chat import ChatMessage, ChatSession
|
| 9 |
+
from app.models.compliance import AuditStatus, ComplianceAudit, ComplianceMatrixRow
|
| 10 |
+
from app.models.document import Document, DocumentCategory, DocumentEmbedding
|
| 11 |
+
from app.models.prompt import CustomPrompt
|
| 12 |
+
from app.models.user import User
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"Base",
|
| 16 |
+
"TimestampMixin",
|
| 17 |
+
"UUIDMixin",
|
| 18 |
+
"User",
|
| 19 |
+
"Document",
|
| 20 |
+
"DocumentEmbedding",
|
| 21 |
+
"DocumentCategory",
|
| 22 |
+
"ChatSession",
|
| 23 |
+
"ChatMessage",
|
| 24 |
+
"AuditLog",
|
| 25 |
+
"AuditAction",
|
| 26 |
+
"CustomPrompt",
|
| 27 |
+
"ComplianceAudit",
|
| 28 |
+
"ComplianceMatrixRow",
|
| 29 |
+
"AuditStatus",
|
| 30 |
+
]
|
app/models/audit.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audit Log Model
|
| 3 |
+
Enterprise compliance logging for all user actions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import JSON, Column, DateTime, String, Text
|
| 11 |
+
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
|
| 12 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 13 |
+
|
| 14 |
+
JSONB = JSON().with_variant(PG_JSONB, "postgresql")
|
| 15 |
+
|
| 16 |
+
from app.models.base import Base, UUIDMixin
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AuditAction(str, Enum):
|
| 20 |
+
"""Types of auditable actions"""
|
| 21 |
+
|
| 22 |
+
# Document actions
|
| 23 |
+
DOCUMENT_UPLOAD = "document.upload"
|
| 24 |
+
DOCUMENT_VIEW = "document.view"
|
| 25 |
+
DOCUMENT_DELETE = "document.delete"
|
| 26 |
+
DOCUMENT_PROCESS = "document.process"
|
| 27 |
+
|
| 28 |
+
# Chat actions
|
| 29 |
+
CHAT_CREATE = "chat.create"
|
| 30 |
+
CHAT_MESSAGE = "chat.message"
|
| 31 |
+
CHAT_DELETE = "chat.delete"
|
| 32 |
+
|
| 33 |
+
# User actions
|
| 34 |
+
USER_LOGIN = "user.login"
|
| 35 |
+
USER_LOGOUT = "user.logout"
|
| 36 |
+
USER_PROFILE_UPDATE = "user.profile_update"
|
| 37 |
+
|
| 38 |
+
# Admin actions
|
| 39 |
+
ADMIN_ACTION = "admin.action"
|
| 40 |
+
|
| 41 |
+
# System actions
|
| 42 |
+
SYSTEM_ERROR = "system.error"
|
| 43 |
+
AI_ANALYSIS = "ai.analysis"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AuditLog(Base, UUIDMixin):
|
| 47 |
+
"""
|
| 48 |
+
Immutable audit log for enterprise compliance.
|
| 49 |
+
Tracks all user actions and system events.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
__tablename__ = "audit_logs"
|
| 53 |
+
|
| 54 |
+
# Who
|
| 55 |
+
user_id = Column(
|
| 56 |
+
String(255), nullable=True, index=True
|
| 57 |
+
) # Nullable for system events
|
| 58 |
+
user_email = Column(String(255), nullable=True)
|
| 59 |
+
|
| 60 |
+
# What
|
| 61 |
+
action = Column(String(100), nullable=False, index=True)
|
| 62 |
+
resource_type = Column(String(50), nullable=True) # document, chat, user
|
| 63 |
+
resource_id = Column(String(255), nullable=True)
|
| 64 |
+
|
| 65 |
+
# Details
|
| 66 |
+
description = Column(Text, nullable=True)
|
| 67 |
+
details = Column(JSONB, default={})
|
| 68 |
+
# Example details:
|
| 69 |
+
# {
|
| 70 |
+
# "file_name": "safety_manual.pdf",
|
| 71 |
+
# "file_size": 1024000,
|
| 72 |
+
# "category": "safety_protocol"
|
| 73 |
+
# }
|
| 74 |
+
|
| 75 |
+
# When
|
| 76 |
+
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
| 77 |
+
|
| 78 |
+
# Where
|
| 79 |
+
ip_address = Column(String(50), nullable=True)
|
| 80 |
+
user_agent = Column(Text, nullable=True)
|
| 81 |
+
|
| 82 |
+
# Outcome
|
| 83 |
+
success = Column(String(10), default="true") # true, false, partial
|
| 84 |
+
error_message = Column(Text, nullable=True)
|
| 85 |
+
|
| 86 |
+
def __repr__(self):
|
| 87 |
+
return f"<AuditLog {self.action} by {self.user_id}>"
|
| 88 |
+
|
| 89 |
+
def to_dict(self):
|
| 90 |
+
"""Convert to dictionary for API responses"""
|
| 91 |
+
return {
|
| 92 |
+
"id": str(self.id),
|
| 93 |
+
"user_id": self.user_id,
|
| 94 |
+
"action": self.action,
|
| 95 |
+
"resource_type": self.resource_type,
|
| 96 |
+
"resource_id": self.resource_id,
|
| 97 |
+
"description": self.description,
|
| 98 |
+
"details": self.details,
|
| 99 |
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
| 100 |
+
"success": self.success,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def create_audit_log(
|
| 105 |
+
action: str,
|
| 106 |
+
user_id: str = None,
|
| 107 |
+
user_email: str = None,
|
| 108 |
+
resource_type: str = None,
|
| 109 |
+
resource_id: str = None,
|
| 110 |
+
description: str = None,
|
| 111 |
+
details: dict = None,
|
| 112 |
+
ip_address: str = None,
|
| 113 |
+
user_agent: str = None,
|
| 114 |
+
success: str = "true",
|
| 115 |
+
error_message: str = None,
|
| 116 |
+
) -> AuditLog:
|
| 117 |
+
"""
|
| 118 |
+
Factory function to create audit log entries.
|
| 119 |
+
|
| 120 |
+
Usage:
|
| 121 |
+
log = create_audit_log(
|
| 122 |
+
action=AuditAction.DOCUMENT_UPLOAD.value,
|
| 123 |
+
user_id=current_user.id,
|
| 124 |
+
resource_type="document",
|
| 125 |
+
resource_id=str(doc.id),
|
| 126 |
+
details={"file_name": doc.file_name}
|
| 127 |
+
)
|
| 128 |
+
db.add(log)
|
| 129 |
+
"""
|
| 130 |
+
return AuditLog(
|
| 131 |
+
action=action,
|
| 132 |
+
user_id=user_id,
|
| 133 |
+
user_email=user_email,
|
| 134 |
+
resource_type=resource_type,
|
| 135 |
+
resource_id=resource_id,
|
| 136 |
+
description=description,
|
| 137 |
+
details=details or {},
|
| 138 |
+
ip_address=ip_address,
|
| 139 |
+
user_agent=user_agent,
|
| 140 |
+
success=success,
|
| 141 |
+
error_message=error_message,
|
| 142 |
+
)
|
app/models/base.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Model Classes
|
| 3 |
+
Mixins and base classes for all SQLAlchemy models
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import Column, DateTime
|
| 10 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 11 |
+
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
| 12 |
+
|
| 13 |
+
Base = declarative_base()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class UUIDMixin:
|
| 17 |
+
"""Mixin that adds a UUID primary key"""
|
| 18 |
+
|
| 19 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TimestampMixin:
|
| 23 |
+
"""Mixin that adds created_at and updated_at timestamps"""
|
| 24 |
+
|
| 25 |
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
| 26 |
+
|
| 27 |
+
updated_at = Column(
|
| 28 |
+
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TableNameMixin:
|
| 33 |
+
"""Mixin that auto-generates table name from class name"""
|
| 34 |
+
|
| 35 |
+
@declared_attr
|
| 36 |
+
def __tablename__(cls):
|
| 37 |
+
# Convert CamelCase to snake_case
|
| 38 |
+
name = cls.__name__
|
| 39 |
+
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip(
|
| 40 |
+
"_"
|
| 41 |
+
)
|
app/models/chat.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat Models
|
| 3 |
+
Chat sessions and messages with RAG context
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text
|
| 10 |
+
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
|
| 11 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 12 |
+
|
| 13 |
+
JSONB = JSON().with_variant(PG_JSONB, "postgresql")
|
| 14 |
+
from sqlalchemy.orm import relationship
|
| 15 |
+
|
| 16 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ChatSession(Base, UUIDMixin, TimestampMixin):
|
| 20 |
+
"""
|
| 21 |
+
Chat session for grouping related messages.
|
| 22 |
+
Each session maintains context for the conversation.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
__tablename__ = "chat_sessions"
|
| 26 |
+
|
| 27 |
+
# Owner
|
| 28 |
+
user_id = Column(
|
| 29 |
+
String(255), ForeignKey("users.clerk_user_id"), nullable=False, index=True
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Session info
|
| 33 |
+
title = Column(String(500), nullable=False, default="New Chat")
|
| 34 |
+
|
| 35 |
+
# Context - selected documents for this session
|
| 36 |
+
document_context = Column(JSONB, default=list) # List of document IDs
|
| 37 |
+
|
| 38 |
+
# Custom prompt if set
|
| 39 |
+
system_prompt = Column(Text, nullable=True)
|
| 40 |
+
|
| 41 |
+
# Metadata
|
| 42 |
+
metadata_ = Column("metadata", JSONB, default=dict)
|
| 43 |
+
|
| 44 |
+
# Relationships
|
| 45 |
+
user = relationship("User", back_populates="chat_sessions")
|
| 46 |
+
messages = relationship(
|
| 47 |
+
"ChatMessage",
|
| 48 |
+
back_populates="session",
|
| 49 |
+
cascade="all, delete-orphan",
|
| 50 |
+
order_by="ChatMessage.created_at",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def message_count(self) -> int:
|
| 55 |
+
return len(self.messages)
|
| 56 |
+
|
| 57 |
+
def __repr__(self):
|
| 58 |
+
return f"<ChatSession {self.title[:30]}...>"
|
| 59 |
+
|
| 60 |
+
def to_dict(self):
|
| 61 |
+
"""Convert to dictionary for API responses"""
|
| 62 |
+
return {
|
| 63 |
+
"id": str(self.id),
|
| 64 |
+
"title": self.title,
|
| 65 |
+
"message_count": self.message_count,
|
| 66 |
+
"document_context": self.document_context,
|
| 67 |
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 68 |
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ChatMessage(Base, UUIDMixin):
|
| 73 |
+
"""
|
| 74 |
+
Individual chat message with RAG source citations.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
__tablename__ = "chat_messages"
|
| 78 |
+
|
| 79 |
+
# Parent session
|
| 80 |
+
session_id = Column(
|
| 81 |
+
UUID(as_uuid=True),
|
| 82 |
+
ForeignKey("chat_sessions.id", ondelete="CASCADE"),
|
| 83 |
+
nullable=False,
|
| 84 |
+
index=True,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Message content
|
| 88 |
+
role = Column(String(20), nullable=False) # "user" or "assistant"
|
| 89 |
+
content = Column(Text, nullable=False)
|
| 90 |
+
|
| 91 |
+
# RAG sources - documents/chunks used for this response
|
| 92 |
+
sources = Column(JSONB, default=[])
|
| 93 |
+
# Structure:
|
| 94 |
+
# [
|
| 95 |
+
# {
|
| 96 |
+
# "document_id": "uuid",
|
| 97 |
+
# "document_title": "Safety Manual",
|
| 98 |
+
# "chunk_text": "...",
|
| 99 |
+
# "relevance_score": 0.95,
|
| 100 |
+
# "page": 5
|
| 101 |
+
# }
|
| 102 |
+
# ]
|
| 103 |
+
|
| 104 |
+
# AI metadata
|
| 105 |
+
model_used = Column(String(100), nullable=True)
|
| 106 |
+
tokens_used = Column(JSONB, nullable=True) # {"input": 100, "output": 50}
|
| 107 |
+
response_time_ms = Column(Integer, nullable=True)
|
| 108 |
+
|
| 109 |
+
# Timestamp
|
| 110 |
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
| 111 |
+
|
| 112 |
+
# Relationships
|
| 113 |
+
session = relationship("ChatSession", back_populates="messages")
|
| 114 |
+
|
| 115 |
+
def __repr__(self):
|
| 116 |
+
return f"<ChatMessage {self.role}: {self.content[:30]}...>"
|
| 117 |
+
|
| 118 |
+
def to_dict(self):
|
| 119 |
+
"""Convert to dictionary for API responses"""
|
| 120 |
+
return {
|
| 121 |
+
"id": str(self.id),
|
| 122 |
+
"role": self.role,
|
| 123 |
+
"content": self.content,
|
| 124 |
+
"sources": self.sources,
|
| 125 |
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 126 |
+
}
|
app/models/compliance.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compliance Audit Models
|
| 3 |
+
Regulatory compliance auto-auditor: cross-references operational documents
|
| 4 |
+
against regulatory documents (MSHA/OSHA/EPA/DGMS) to produce per-clause
|
| 5 |
+
compliance matrices with citations.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from enum import Enum
|
| 11 |
+
|
| 12 |
+
from sqlalchemy import JSON, Column, DateTime
|
| 13 |
+
from sqlalchemy import Enum as SQLEnum
|
| 14 |
+
from sqlalchemy import Float, ForeignKey, Integer, String, Text
|
| 15 |
+
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
|
| 16 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 17 |
+
|
| 18 |
+
JSONB = JSON().with_variant(PG_JSONB, "postgresql")
|
| 19 |
+
from sqlalchemy.orm import relationship
|
| 20 |
+
|
| 21 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AuditStatus(str, Enum):
|
| 25 |
+
"""Compliance audit processing status"""
|
| 26 |
+
|
| 27 |
+
PENDING = "pending"
|
| 28 |
+
RUNNING = "running"
|
| 29 |
+
COMPLETED = "completed"
|
| 30 |
+
FAILED = "failed"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ComplianceAudit(Base, UUIDMixin, TimestampMixin):
|
| 34 |
+
"""
|
| 35 |
+
A compliance audit that cross-references one regulatory document
|
| 36 |
+
against a set of operational documents.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
__tablename__ = "compliance_audits"
|
| 40 |
+
|
| 41 |
+
# Owner
|
| 42 |
+
user_id = Column(
|
| 43 |
+
String(255),
|
| 44 |
+
ForeignKey("users.clerk_user_id"),
|
| 45 |
+
nullable=False,
|
| 46 |
+
index=True,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
title = Column(String(500), nullable=False)
|
| 50 |
+
|
| 51 |
+
# The regulatory document being audited against
|
| 52 |
+
regulation_doc_id = Column(
|
| 53 |
+
UUID(as_uuid=True),
|
| 54 |
+
ForeignKey("documents.id"),
|
| 55 |
+
nullable=False,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# List of operational document UUIDs being audited
|
| 59 |
+
operational_doc_ids = Column(JSONB, nullable=False, default=list)
|
| 60 |
+
|
| 61 |
+
# Status
|
| 62 |
+
status = Column(
|
| 63 |
+
SQLEnum(AuditStatus),
|
| 64 |
+
default=AuditStatus.PENDING,
|
| 65 |
+
nullable=False,
|
| 66 |
+
index=True,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Aggregate stats
|
| 70 |
+
total_clauses = Column(Integer, nullable=True)
|
| 71 |
+
processed_clauses = Column(Integer, default=0, nullable=False)
|
| 72 |
+
compliant_count = Column(Integer, nullable=True)
|
| 73 |
+
gap_count = Column(Integer, nullable=True)
|
| 74 |
+
missing_count = Column(Integer, nullable=True)
|
| 75 |
+
overall_score = Column(Float, nullable=True) # 0-100
|
| 76 |
+
|
| 77 |
+
processing_error = Column(Text, nullable=True)
|
| 78 |
+
completed_at = Column(DateTime, nullable=True)
|
| 79 |
+
|
| 80 |
+
# Relationships
|
| 81 |
+
rows = relationship(
|
| 82 |
+
"ComplianceMatrixRow",
|
| 83 |
+
back_populates="audit",
|
| 84 |
+
cascade="all, delete-orphan",
|
| 85 |
+
)
|
| 86 |
+
regulation_doc = relationship(
|
| 87 |
+
"Document",
|
| 88 |
+
foreign_keys=[regulation_doc_id],
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def __repr__(self):
|
| 92 |
+
return f"<ComplianceAudit {self.title[:50]}... status={self.status.value}>"
|
| 93 |
+
|
| 94 |
+
def to_dict(self):
|
| 95 |
+
return {
|
| 96 |
+
"id": str(self.id),
|
| 97 |
+
"title": self.title,
|
| 98 |
+
"regulation_doc_id": str(self.regulation_doc_id),
|
| 99 |
+
"operational_doc_ids": [str(d) for d in (self.operational_doc_ids or [])],
|
| 100 |
+
"status": self.status.value if self.status else None,
|
| 101 |
+
"total_clauses": self.total_clauses,
|
| 102 |
+
"processed_clauses": self.processed_clauses,
|
| 103 |
+
"compliant_count": self.compliant_count,
|
| 104 |
+
"gap_count": self.gap_count,
|
| 105 |
+
"missing_count": self.missing_count,
|
| 106 |
+
"overall_score": self.overall_score,
|
| 107 |
+
"processing_error": self.processing_error,
|
| 108 |
+
"completed_at": (
|
| 109 |
+
self.completed_at.replace(tzinfo=timezone.utc).isoformat()
|
| 110 |
+
if self.completed_at
|
| 111 |
+
else None
|
| 112 |
+
),
|
| 113 |
+
"created_at": (
|
| 114 |
+
self.created_at.replace(tzinfo=timezone.utc).isoformat()
|
| 115 |
+
if self.created_at
|
| 116 |
+
else None
|
| 117 |
+
),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class ComplianceMatrixRow(Base, UUIDMixin):
|
| 122 |
+
"""
|
| 123 |
+
A single row in the compliance matrix: one regulation clause
|
| 124 |
+
assessed against operational document evidence.
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
__tablename__ = "compliance_matrix_rows"
|
| 128 |
+
|
| 129 |
+
audit_id = Column(
|
| 130 |
+
UUID(as_uuid=True),
|
| 131 |
+
ForeignKey("compliance_audits.id", ondelete="CASCADE"),
|
| 132 |
+
nullable=False,
|
| 133 |
+
index=True,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
clause_index = Column(Integer, nullable=False)
|
| 137 |
+
clause_text = Column(Text, nullable=False)
|
| 138 |
+
section_title = Column(String(500), nullable=True)
|
| 139 |
+
|
| 140 |
+
# compliant | gap | missing
|
| 141 |
+
status = Column(String(50), nullable=False)
|
| 142 |
+
assessment = Column(Text, nullable=False) # LLM explanation
|
| 143 |
+
confidence = Column(Float, nullable=False) # 0.0-1.0
|
| 144 |
+
|
| 145 |
+
# Evidence chunks that informed the assessment
|
| 146 |
+
evidence_chunks = Column(JSONB, nullable=True)
|
| 147 |
+
# Structure: [{"chunk_text": "...", "document_title": "...",
|
| 148 |
+
# "page_numbers": [12,13], "relevance_score": 0.87}]
|
| 149 |
+
|
| 150 |
+
recommendations = Column(JSONB, nullable=True) # list of strings
|
| 151 |
+
|
| 152 |
+
# Relationship
|
| 153 |
+
audit = relationship("ComplianceAudit", back_populates="rows")
|
| 154 |
+
|
| 155 |
+
def __repr__(self):
|
| 156 |
+
return f"<ComplianceMatrixRow clause={self.clause_index} status={self.status}>"
|
app/models/document.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document Models
|
| 3 |
+
Document storage, classification, and embeddings
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from enum import Enum
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import JSON, Column, DateTime
|
| 11 |
+
from sqlalchemy import Enum as SQLEnum
|
| 12 |
+
from sqlalchemy import Float, ForeignKey, Integer, String, Text
|
| 13 |
+
from sqlalchemy.dialects.postgresql import ARRAY
|
| 14 |
+
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
|
| 15 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 16 |
+
|
| 17 |
+
JSONB = JSON().with_variant(PG_JSONB, "postgresql")
|
| 18 |
+
from pgvector.sqlalchemy import Vector
|
| 19 |
+
from sqlalchemy.orm import relationship
|
| 20 |
+
|
| 21 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class DocumentCategory(str, Enum):
|
| 25 |
+
"""Mining document categories for classification"""
|
| 26 |
+
|
| 27 |
+
SAFETY_PROTOCOL = "safety_protocol"
|
| 28 |
+
EQUIPMENT_MANUAL = "equipment_manual"
|
| 29 |
+
REGULATORY = "regulatory"
|
| 30 |
+
INCIDENT_REPORT = "incident_report"
|
| 31 |
+
GEOLOGICAL = "geological"
|
| 32 |
+
ENVIRONMENTAL = "environmental"
|
| 33 |
+
TRAINING = "training"
|
| 34 |
+
PERMIT = "permit"
|
| 35 |
+
MAINTENANCE = "maintenance"
|
| 36 |
+
OTHER = "other"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class DocumentStatus(str, Enum):
|
| 40 |
+
"""Document processing status"""
|
| 41 |
+
|
| 42 |
+
PENDING = "pending"
|
| 43 |
+
PROCESSING = "processing"
|
| 44 |
+
ANALYZING = "analyzing"
|
| 45 |
+
COMPLETED = "completed"
|
| 46 |
+
FAILED = "failed"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ComplianceStatus(str, Enum):
|
| 50 |
+
"""Safety compliance status"""
|
| 51 |
+
|
| 52 |
+
COMPLIANT = "compliant"
|
| 53 |
+
WARNING = "warning"
|
| 54 |
+
VIOLATION = "violation"
|
| 55 |
+
PENDING = "pending"
|
| 56 |
+
NOT_APPLICABLE = "not_applicable"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class Document(Base, UUIDMixin, TimestampMixin):
|
| 60 |
+
"""
|
| 61 |
+
Document model with AI-enhanced metadata.
|
| 62 |
+
Stores file info, classification, safety analysis, and extracted entities.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
__tablename__ = "documents"
|
| 66 |
+
|
| 67 |
+
# Owner
|
| 68 |
+
user_id = Column(
|
| 69 |
+
String(255), ForeignKey("users.clerk_user_id"), nullable=False, index=True
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# File information
|
| 73 |
+
title = Column(String(500), nullable=False)
|
| 74 |
+
file_name = Column(String(500), nullable=False)
|
| 75 |
+
file_size = Column(Integer, nullable=False) # bytes
|
| 76 |
+
file_type = Column(String(100), nullable=False) # MIME type
|
| 77 |
+
file_url = Column(Text, nullable=False)
|
| 78 |
+
|
| 79 |
+
# Processing status
|
| 80 |
+
status = Column(
|
| 81 |
+
SQLEnum(DocumentStatus),
|
| 82 |
+
default=DocumentStatus.PENDING,
|
| 83 |
+
nullable=False,
|
| 84 |
+
index=True,
|
| 85 |
+
)
|
| 86 |
+
processing_error = Column(Text, nullable=True)
|
| 87 |
+
processed_at = Column(DateTime, nullable=True)
|
| 88 |
+
|
| 89 |
+
# Extracted content
|
| 90 |
+
content = Column(Text, nullable=True) # Full text content
|
| 91 |
+
page_count = Column(Integer, nullable=True) # deprecated alias — use total_pages
|
| 92 |
+
total_pages = Column(
|
| 93 |
+
Integer, nullable=True
|
| 94 |
+
) # authoritative page count from extractor
|
| 95 |
+
word_count = Column(Integer, nullable=True)
|
| 96 |
+
|
| 97 |
+
# AI Classification
|
| 98 |
+
category = Column(
|
| 99 |
+
SQLEnum(DocumentCategory),
|
| 100 |
+
default=DocumentCategory.OTHER,
|
| 101 |
+
nullable=True,
|
| 102 |
+
index=True,
|
| 103 |
+
)
|
| 104 |
+
subcategory = Column(String(100), nullable=True)
|
| 105 |
+
classification_confidence = Column(Float, nullable=True) # 0.0 - 1.0
|
| 106 |
+
|
| 107 |
+
# AI Summary
|
| 108 |
+
summary = Column(Text, nullable=True) # AI-generated summary
|
| 109 |
+
key_points = Column(JSONB, nullable=True) # List of key points
|
| 110 |
+
|
| 111 |
+
# Safety Analysis
|
| 112 |
+
safety_score = Column(Float, nullable=True) # 0-100
|
| 113 |
+
compliance_status = Column(
|
| 114 |
+
SQLEnum(ComplianceStatus), default=ComplianceStatus.PENDING, nullable=True
|
| 115 |
+
)
|
| 116 |
+
hazards_detected = Column(JSONB, nullable=True) # List of hazards
|
| 117 |
+
safety_recommendations = Column(JSONB, nullable=True)
|
| 118 |
+
|
| 119 |
+
# Named Entity Recognition
|
| 120 |
+
entities = Column(JSONB, nullable=True)
|
| 121 |
+
# Structure:
|
| 122 |
+
# {
|
| 123 |
+
# "equipment": ["Caterpillar D11", "Komatsu PC8000"],
|
| 124 |
+
# "chemicals": ["methane", "coal dust"],
|
| 125 |
+
# "locations": ["Mine Site A", "Section 4B"],
|
| 126 |
+
# "personnel": ["John Smith", "Safety Team"],
|
| 127 |
+
# "dates": ["2024-01-15", "Q1 2024"],
|
| 128 |
+
# "regulations": ["MSHA 30 CFR 75.400", "OSHA 1910.134"]
|
| 129 |
+
# }
|
| 130 |
+
|
| 131 |
+
# Extra Metadata
|
| 132 |
+
extra_metadata = Column("metadata", JSONB, default=dict)
|
| 133 |
+
tags = Column(JSONB, default=list)
|
| 134 |
+
|
| 135 |
+
# Relationships
|
| 136 |
+
user = relationship("User", back_populates="documents")
|
| 137 |
+
embeddings = relationship(
|
| 138 |
+
"DocumentEmbedding", back_populates="document", cascade="all, delete-orphan"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def __repr__(self):
|
| 142 |
+
return f"<Document {self.title[:50]}...>"
|
| 143 |
+
|
| 144 |
+
def to_dict(self):
|
| 145 |
+
"""Convert to dictionary for API responses"""
|
| 146 |
+
return {
|
| 147 |
+
"id": str(self.id),
|
| 148 |
+
"title": self.title,
|
| 149 |
+
"file_name": self.file_name,
|
| 150 |
+
"file_size": self.file_size,
|
| 151 |
+
"file_type": self.file_type,
|
| 152 |
+
"file_url": self.file_url,
|
| 153 |
+
"status": self.status.value if self.status else None,
|
| 154 |
+
"category": self.category.value if self.category else None,
|
| 155 |
+
"subcategory": self.subcategory,
|
| 156 |
+
"classification_confidence": self.classification_confidence,
|
| 157 |
+
"summary": self.summary,
|
| 158 |
+
"key_points": self.key_points,
|
| 159 |
+
"safety_score": self.safety_score,
|
| 160 |
+
"compliance_status": (
|
| 161 |
+
self.compliance_status.value if self.compliance_status else None
|
| 162 |
+
),
|
| 163 |
+
"hazards_detected": self.hazards_detected,
|
| 164 |
+
"entities": {
|
| 165 |
+
k: v if isinstance(v, list) else []
|
| 166 |
+
for k, v in (self.entities or {}).items()
|
| 167 |
+
}
|
| 168 |
+
or None,
|
| 169 |
+
"page_count": self.page_count,
|
| 170 |
+
"word_count": self.word_count,
|
| 171 |
+
"created_at": (
|
| 172 |
+
self.created_at.replace(tzinfo=timezone.utc).isoformat()
|
| 173 |
+
if self.created_at
|
| 174 |
+
else None
|
| 175 |
+
),
|
| 176 |
+
"processed_at": (
|
| 177 |
+
self.processed_at.replace(tzinfo=timezone.utc).isoformat()
|
| 178 |
+
if self.processed_at
|
| 179 |
+
else None
|
| 180 |
+
),
|
| 181 |
+
"total_pages": self.total_pages or self.page_count,
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class DocumentEmbedding(Base, UUIDMixin):
|
| 186 |
+
"""
|
| 187 |
+
Vector embeddings for document chunks.
|
| 188 |
+
Used for semantic search and RAG.
|
| 189 |
+
|
| 190 |
+
The embedding column uses pgvector's native Vector(768) type with an
|
| 191 |
+
HNSW index (see migration 001) for sub-5ms approximate nearest-neighbor
|
| 192 |
+
search instead of brute-force Python cosine similarity.
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
__tablename__ = "document_embeddings"
|
| 196 |
+
|
| 197 |
+
# Parent document
|
| 198 |
+
document_id = Column(
|
| 199 |
+
UUID(as_uuid=True),
|
| 200 |
+
ForeignKey("documents.id", ondelete="CASCADE"),
|
| 201 |
+
nullable=False,
|
| 202 |
+
index=True,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Chunk information
|
| 206 |
+
chunk_index = Column(Integer, nullable=False)
|
| 207 |
+
chunk_text = Column(Text, nullable=False)
|
| 208 |
+
|
| 209 |
+
# Vector embedding — native pgvector type with HNSW index (see migration 001)
|
| 210 |
+
# Replaces the old JSONB column for 10-100x faster similarity search.
|
| 211 |
+
embedding = Column(Vector(768), nullable=False)
|
| 212 |
+
embedding_model = Column(String(100), default="text-embedding-004")
|
| 213 |
+
|
| 214 |
+
# Context metadata — powers context-aware answers with page citations
|
| 215 |
+
section_title = Column(String(500), nullable=True) # e.g. "Safety Procedures"
|
| 216 |
+
page_numbers = Column(
|
| 217 |
+
JSONB, nullable=True
|
| 218 |
+
) # e.g. [12, 13] — pages this chunk spans
|
| 219 |
+
|
| 220 |
+
# Legacy page columns (kept for backward compat, use page_numbers instead)
|
| 221 |
+
start_page = Column(Integer, nullable=True)
|
| 222 |
+
end_page = Column(Integer, nullable=True)
|
| 223 |
+
|
| 224 |
+
extra_metadata = Column("metadata", JSONB, default=dict)
|
| 225 |
+
|
| 226 |
+
# Relationships
|
| 227 |
+
document = relationship("Document", back_populates="embeddings")
|
| 228 |
+
|
| 229 |
+
def __repr__(self):
|
| 230 |
+
return f"<DocumentEmbedding doc={self.document_id} chunk={self.chunk_index}>"
|
app/models/prompt.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom Prompt Model
|
| 3 |
+
User-defined AI prompts for specialized mining document analysis
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
| 10 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 11 |
+
from sqlalchemy.orm import relationship
|
| 12 |
+
|
| 13 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CustomPrompt(Base, UUIDMixin, TimestampMixin):
|
| 17 |
+
"""
|
| 18 |
+
User-defined custom prompts for AI analysis.
|
| 19 |
+
Allows users to save specialized prompts for safety reviews,
|
| 20 |
+
compliance checks, equipment inspections, etc.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
__tablename__ = "custom_prompts"
|
| 24 |
+
|
| 25 |
+
# Owner
|
| 26 |
+
user_id = Column(
|
| 27 |
+
String(255), ForeignKey("users.clerk_user_id"), nullable=False, index=True
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Prompt info
|
| 31 |
+
name = Column(String(255), nullable=False)
|
| 32 |
+
prompt_text = Column(Text, nullable=False)
|
| 33 |
+
description = Column(Text, nullable=True)
|
| 34 |
+
|
| 35 |
+
# Category/type for UI grouping
|
| 36 |
+
category = Column(
|
| 37 |
+
String(100), nullable=True
|
| 38 |
+
) # e.g., "safety", "compliance", "equipment"
|
| 39 |
+
|
| 40 |
+
# Whether this is a default/system prompt
|
| 41 |
+
is_default = Column(Boolean, default=False, nullable=False)
|
| 42 |
+
|
| 43 |
+
# Usage tracking
|
| 44 |
+
use_count = Column(Integer, default=0, nullable=False)
|
| 45 |
+
|
| 46 |
+
# Relationships
|
| 47 |
+
user = relationship("User", back_populates="custom_prompts")
|
| 48 |
+
|
| 49 |
+
def __repr__(self):
|
| 50 |
+
return f"<CustomPrompt {self.name}>"
|
| 51 |
+
|
| 52 |
+
def to_dict(self):
|
| 53 |
+
"""Convert to dictionary for API responses"""
|
| 54 |
+
return {
|
| 55 |
+
"id": str(self.id),
|
| 56 |
+
"name": self.name,
|
| 57 |
+
"prompt": self.prompt_text,
|
| 58 |
+
"description": self.description,
|
| 59 |
+
"category": self.category,
|
| 60 |
+
"is_default": self.is_default,
|
| 61 |
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 62 |
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
| 63 |
+
}
|
app/models/user.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Model
|
| 3 |
+
User profile and organization management
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import JSON, Boolean, Column, DateTime, String, Text
|
| 10 |
+
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
|
| 11 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 12 |
+
|
| 13 |
+
JSONB = JSON().with_variant(PG_JSONB, "postgresql")
|
| 14 |
+
from sqlalchemy.orm import relationship
|
| 15 |
+
|
| 16 |
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class User(Base, UUIDMixin, TimestampMixin):
|
| 20 |
+
"""
|
| 21 |
+
User model linked to Clerk authentication.
|
| 22 |
+
Stores user profile and preferences.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
__tablename__ = "users"
|
| 26 |
+
|
| 27 |
+
# Clerk integration
|
| 28 |
+
clerk_user_id = Column(String(255), unique=True, nullable=False, index=True)
|
| 29 |
+
email = Column(String(255), nullable=True, index=True)
|
| 30 |
+
|
| 31 |
+
# Profile
|
| 32 |
+
full_name = Column(String(255), nullable=True)
|
| 33 |
+
avatar_url = Column(Text, nullable=True)
|
| 34 |
+
|
| 35 |
+
# Organization/Company (for enterprise)
|
| 36 |
+
company_name = Column(String(255), nullable=True)
|
| 37 |
+
company_role = Column(
|
| 38 |
+
String(100), nullable=True
|
| 39 |
+
) # Safety Officer, Engineer, Manager
|
| 40 |
+
|
| 41 |
+
# Mining-specific
|
| 42 |
+
industry_focus = Column(JSONB, nullable=True) # ["coal", "underground", "surface"]
|
| 43 |
+
mine_sites = Column(JSONB, nullable=True) # Associated mine sites
|
| 44 |
+
|
| 45 |
+
# Preferences
|
| 46 |
+
preferences = Column(JSONB, default={})
|
| 47 |
+
|
| 48 |
+
# Status
|
| 49 |
+
is_active = Column(Boolean, default=True)
|
| 50 |
+
last_login = Column(DateTime, nullable=True)
|
| 51 |
+
|
| 52 |
+
# Relationships
|
| 53 |
+
documents = relationship(
|
| 54 |
+
"Document", back_populates="user", cascade="all, delete-orphan"
|
| 55 |
+
)
|
| 56 |
+
chat_sessions = relationship(
|
| 57 |
+
"ChatSession", back_populates="user", cascade="all, delete-orphan"
|
| 58 |
+
)
|
| 59 |
+
custom_prompts = relationship(
|
| 60 |
+
"CustomPrompt", back_populates="user", cascade="all, delete-orphan"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
def __repr__(self):
|
| 64 |
+
return f"<User {self.email or self.clerk_user_id}>"
|
| 65 |
+
|
| 66 |
+
def to_dict(self):
|
| 67 |
+
"""Convert to dictionary for API responses"""
|
| 68 |
+
return {
|
| 69 |
+
"id": str(self.id),
|
| 70 |
+
"clerk_user_id": self.clerk_user_id,
|
| 71 |
+
"email": self.email,
|
| 72 |
+
"full_name": self.full_name,
|
| 73 |
+
"company_name": self.company_name,
|
| 74 |
+
"company_role": self.company_role,
|
| 75 |
+
"industry_focus": self.industry_focus,
|
| 76 |
+
"is_active": self.is_active,
|
| 77 |
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 78 |
+
}
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic Schemas
|
| 3 |
+
Request and response models for API validation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.schemas.analytics import DashboardStats, DocumentAnalytics, SafetyAnalytics
|
| 7 |
+
from app.schemas.chat import (
|
| 8 |
+
ChatMessageResponse,
|
| 9 |
+
ChatRequest,
|
| 10 |
+
ChatResponse,
|
| 11 |
+
ChatSessionCreate,
|
| 12 |
+
ChatSessionResponse,
|
| 13 |
+
)
|
| 14 |
+
from app.schemas.common import (
|
| 15 |
+
ErrorResponse,
|
| 16 |
+
HealthResponse,
|
| 17 |
+
JobStatusResponse,
|
| 18 |
+
PaginatedResponse,
|
| 19 |
+
)
|
| 20 |
+
from app.schemas.document import (
|
| 21 |
+
DocumentAnalysisResponse,
|
| 22 |
+
DocumentCreate,
|
| 23 |
+
DocumentListResponse,
|
| 24 |
+
DocumentResponse,
|
| 25 |
+
DocumentUploadResponse,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
__all__ = [
|
| 29 |
+
# Document
|
| 30 |
+
"DocumentCreate",
|
| 31 |
+
"DocumentResponse",
|
| 32 |
+
"DocumentListResponse",
|
| 33 |
+
"DocumentUploadResponse",
|
| 34 |
+
"DocumentAnalysisResponse",
|
| 35 |
+
# Chat
|
| 36 |
+
"ChatRequest",
|
| 37 |
+
"ChatResponse",
|
| 38 |
+
"ChatSessionCreate",
|
| 39 |
+
"ChatSessionResponse",
|
| 40 |
+
"ChatMessageResponse",
|
| 41 |
+
# Analytics
|
| 42 |
+
"DashboardStats",
|
| 43 |
+
"DocumentAnalytics",
|
| 44 |
+
"SafetyAnalytics",
|
| 45 |
+
# Common
|
| 46 |
+
"HealthResponse",
|
| 47 |
+
"ErrorResponse",
|
| 48 |
+
"PaginatedResponse",
|
| 49 |
+
"JobStatusResponse",
|
| 50 |
+
]
|
app/schemas/analytics.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analytics Schemas
|
| 3 |
+
Pydantic models for dashboard and analytics endpoints
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import date, datetime
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CategoryCount(BaseModel):
|
| 13 |
+
"""Count by document category"""
|
| 14 |
+
|
| 15 |
+
category: str
|
| 16 |
+
count: int
|
| 17 |
+
percentage: float
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class StatusCount(BaseModel):
|
| 21 |
+
"""Count by document status"""
|
| 22 |
+
|
| 23 |
+
status: str
|
| 24 |
+
count: int
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SafetyDistribution(BaseModel):
|
| 28 |
+
"""Safety score distribution"""
|
| 29 |
+
|
| 30 |
+
range: str # "0-25", "26-50", "51-75", "76-100"
|
| 31 |
+
count: int
|
| 32 |
+
percentage: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class DashboardStats(BaseModel):
|
| 36 |
+
"""Main dashboard statistics"""
|
| 37 |
+
|
| 38 |
+
# Document stats
|
| 39 |
+
total_documents: int = 0
|
| 40 |
+
processed_documents: int = 0
|
| 41 |
+
pending_documents: int = 0
|
| 42 |
+
failed_documents: int = 0
|
| 43 |
+
|
| 44 |
+
# Chat stats
|
| 45 |
+
total_chat_sessions: int = 0
|
| 46 |
+
total_messages: int = 0
|
| 47 |
+
|
| 48 |
+
# Safety stats
|
| 49 |
+
average_safety_score: Optional[float] = None
|
| 50 |
+
documents_with_hazards: int = 0
|
| 51 |
+
compliance_violations: int = 0
|
| 52 |
+
compliance_warnings: int = 0
|
| 53 |
+
|
| 54 |
+
# Processing stats
|
| 55 |
+
documents_processed_today: int = 0
|
| 56 |
+
documents_processed_this_week: int = 0
|
| 57 |
+
|
| 58 |
+
# Category breakdown
|
| 59 |
+
documents_by_category: List[CategoryCount] = []
|
| 60 |
+
|
| 61 |
+
# Recent activity
|
| 62 |
+
last_upload_at: Optional[datetime] = None
|
| 63 |
+
last_chat_at: Optional[datetime] = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class DocumentAnalytics(BaseModel):
|
| 67 |
+
"""Detailed document analytics"""
|
| 68 |
+
|
| 69 |
+
# Time series
|
| 70 |
+
uploads_by_day: List[Dict[str, Any]] = [] # [{"date": "2024-01-15", "count": 5}]
|
| 71 |
+
processing_times: List[Dict[str, Any]] = (
|
| 72 |
+
[]
|
| 73 |
+
) # [{"date": "...", "avg_time_ms": 1500}]
|
| 74 |
+
|
| 75 |
+
# Category distribution
|
| 76 |
+
by_category: List[CategoryCount] = []
|
| 77 |
+
|
| 78 |
+
# Status distribution
|
| 79 |
+
by_status: List[StatusCount] = []
|
| 80 |
+
|
| 81 |
+
# File type distribution
|
| 82 |
+
by_file_type: List[Dict[str, Any]] = []
|
| 83 |
+
|
| 84 |
+
# Top documents by views
|
| 85 |
+
top_documents: List[Dict[str, Any]] = []
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class SafetyAnalytics(BaseModel):
|
| 89 |
+
"""Safety compliance analytics"""
|
| 90 |
+
|
| 91 |
+
# Overall scores
|
| 92 |
+
average_safety_score: float = 0
|
| 93 |
+
median_safety_score: Optional[float] = None
|
| 94 |
+
min_safety_score: Optional[float] = None
|
| 95 |
+
max_safety_score: Optional[float] = None
|
| 96 |
+
|
| 97 |
+
# Distribution
|
| 98 |
+
score_distribution: List[SafetyDistribution] = []
|
| 99 |
+
|
| 100 |
+
# Compliance
|
| 101 |
+
compliant_count: int = 0
|
| 102 |
+
warning_count: int = 0
|
| 103 |
+
violation_count: int = 0
|
| 104 |
+
|
| 105 |
+
# Hazards
|
| 106 |
+
total_hazards_detected: int = 0
|
| 107 |
+
hazards_by_type: List[Dict[str, Any]] = [] # [{"type": "fall hazard", "count": 10}]
|
| 108 |
+
|
| 109 |
+
# Trend
|
| 110 |
+
safety_trend: List[Dict[str, Any]] = [] # [{"date": "...", "avg_score": 75}]
|
| 111 |
+
|
| 112 |
+
# Top issues
|
| 113 |
+
top_safety_concerns: List[Dict[str, Any]] = []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class EntityAnalytics(BaseModel):
|
| 117 |
+
"""Named entity analytics"""
|
| 118 |
+
|
| 119 |
+
# Equipment mentioned
|
| 120 |
+
top_equipment: List[Dict[str, Any]] = (
|
| 121 |
+
[]
|
| 122 |
+
) # [{"name": "Caterpillar D11", "mentions": 25}]
|
| 123 |
+
|
| 124 |
+
# Locations
|
| 125 |
+
top_locations: List[Dict[str, Any]] = []
|
| 126 |
+
|
| 127 |
+
# Chemicals
|
| 128 |
+
chemicals_mentioned: List[Dict[str, Any]] = []
|
| 129 |
+
|
| 130 |
+
# Regulations referenced
|
| 131 |
+
regulations_cited: List[Dict[str, Any]] = []
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class AnalyticsSummary(BaseModel):
|
| 135 |
+
"""Combined analytics summary"""
|
| 136 |
+
|
| 137 |
+
documents: DocumentAnalytics
|
| 138 |
+
safety: SafetyAnalytics
|
| 139 |
+
entities: EntityAnalytics
|
| 140 |
+
generated_at: datetime = Field(default_factory=datetime.utcnow)
|