Milan Soni commited on
Commit
3a7eb07
·
1 Parent(s): 4d8d7fe

Deploy MiningNiti API with production RAG pipeline

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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: Miningniti Api
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
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)