File size: 8,250 Bytes
3a7eb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e86dfae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a7eb07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""
Document Models
Document storage, classification, and embeddings
"""

import uuid
from datetime import datetime, timezone
from enum import Enum

from sqlalchemy import JSON, Column, DateTime
from sqlalchemy import Enum as SQLEnum
from sqlalchemy import Float, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
from sqlalchemy.dialects.postgresql import UUID

JSONB = JSON().with_variant(PG_JSONB, "postgresql")
from pgvector.sqlalchemy import Vector
from sqlalchemy.orm import relationship

from app.models.base import Base, TimestampMixin, UUIDMixin


class DocumentCategory(str, Enum):
    """Mining document categories for classification"""

    SAFETY_PROTOCOL = "safety_protocol"
    EQUIPMENT_MANUAL = "equipment_manual"
    REGULATORY = "regulatory"
    INCIDENT_REPORT = "incident_report"
    GEOLOGICAL = "geological"
    ENVIRONMENTAL = "environmental"
    TRAINING = "training"
    PERMIT = "permit"
    MAINTENANCE = "maintenance"
    OTHER = "other"


class DocumentStatus(str, Enum):
    """Document processing status"""

    PENDING = "pending"
    PROCESSING = "processing"
    ANALYZING = "analyzing"
    COMPLETED = "completed"
    FAILED = "failed"


def db_status(member: DocumentStatus) -> str:
    """
    The literal PostgreSQL actually stores for a DocumentStatus.

    SQLAlchemy's Enum type persists member *names* ('COMPLETED'), not values
    ('completed'), unless values_callable is set. ORM queries are coerced
    automatically, but raw SQL is not — so hand-written SQL must compare
    against this, never against `.value`.

    Anything that changes how the column is persisted must change this function
    too; routing every raw-SQL comparison through here means retrieval fails
    loudly at one place instead of silently returning zero rows everywhere.
    """
    return member.name


class ComplianceStatus(str, Enum):
    """Safety compliance status"""

    COMPLIANT = "compliant"
    WARNING = "warning"
    VIOLATION = "violation"
    PENDING = "pending"
    NOT_APPLICABLE = "not_applicable"


class Document(Base, UUIDMixin, TimestampMixin):
    """
    Document model with AI-enhanced metadata.
    Stores file info, classification, safety analysis, and extracted entities.
    """

    __tablename__ = "documents"

    # Owner
    user_id = Column(
        String(255), ForeignKey("users.clerk_user_id"), nullable=False, index=True
    )

    # File information
    title = Column(String(500), nullable=False)
    file_name = Column(String(500), nullable=False)
    file_size = Column(Integer, nullable=False)  # bytes
    file_type = Column(String(100), nullable=False)  # MIME type
    file_url = Column(Text, nullable=False)

    # Processing status
    status = Column(
        SQLEnum(DocumentStatus),
        default=DocumentStatus.PENDING,
        nullable=False,
        index=True,
    )
    processing_error = Column(Text, nullable=True)
    processed_at = Column(DateTime, nullable=True)

    # Extracted content
    content = Column(Text, nullable=True)  # Full text content
    page_count = Column(Integer, nullable=True)  # deprecated alias — use total_pages
    total_pages = Column(
        Integer, nullable=True
    )  # authoritative page count from extractor
    word_count = Column(Integer, nullable=True)

    # AI Classification
    category = Column(
        SQLEnum(DocumentCategory),
        default=DocumentCategory.OTHER,
        nullable=True,
        index=True,
    )
    subcategory = Column(String(100), nullable=True)
    classification_confidence = Column(Float, nullable=True)  # 0.0 - 1.0

    # AI Summary
    summary = Column(Text, nullable=True)  # AI-generated summary
    key_points = Column(JSONB, nullable=True)  # List of key points

    # Safety Analysis
    safety_score = Column(Float, nullable=True)  # 0-100
    compliance_status = Column(
        SQLEnum(ComplianceStatus), default=ComplianceStatus.PENDING, nullable=True
    )
    hazards_detected = Column(JSONB, nullable=True)  # List of hazards
    safety_recommendations = Column(JSONB, nullable=True)

    # Named Entity Recognition
    entities = Column(JSONB, nullable=True)
    # Structure:
    # {
    #   "equipment": ["Caterpillar D11", "Komatsu PC8000"],
    #   "chemicals": ["methane", "coal dust"],
    #   "locations": ["Mine Site A", "Section 4B"],
    #   "personnel": ["John Smith", "Safety Team"],
    #   "dates": ["2024-01-15", "Q1 2024"],
    #   "regulations": ["MSHA 30 CFR 75.400", "OSHA 1910.134"]
    # }

    # Extra Metadata
    extra_metadata = Column("metadata", JSONB, default=dict)
    tags = Column(JSONB, default=list)

    # Relationships
    user = relationship("User", back_populates="documents")
    embeddings = relationship(
        "DocumentEmbedding", back_populates="document", cascade="all, delete-orphan"
    )

    def __repr__(self):
        return f"<Document {self.title[:50]}...>"

    def to_dict(self):
        """Convert to dictionary for API responses"""
        return {
            "id": str(self.id),
            "title": self.title,
            "file_name": self.file_name,
            "file_size": self.file_size,
            "file_type": self.file_type,
            "file_url": self.file_url,
            "status": self.status.value if self.status else None,
            "category": self.category.value if self.category else None,
            "subcategory": self.subcategory,
            "classification_confidence": self.classification_confidence,
            "summary": self.summary,
            "key_points": self.key_points,
            "safety_score": self.safety_score,
            "compliance_status": (
                self.compliance_status.value if self.compliance_status else None
            ),
            "hazards_detected": self.hazards_detected,
            "entities": {
                k: v if isinstance(v, list) else []
                for k, v in (self.entities or {}).items()
            }
            or None,
            "page_count": self.page_count,
            "word_count": self.word_count,
            "created_at": (
                self.created_at.replace(tzinfo=timezone.utc).isoformat()
                if self.created_at
                else None
            ),
            "processed_at": (
                self.processed_at.replace(tzinfo=timezone.utc).isoformat()
                if self.processed_at
                else None
            ),
            "total_pages": self.total_pages or self.page_count,
        }


class DocumentEmbedding(Base, UUIDMixin):
    """
    Vector embeddings for document chunks.
    Used for semantic search and RAG.

    The embedding column uses pgvector's native Vector(768) type with an
    HNSW index (see migration 001) for sub-5ms approximate nearest-neighbor
    search instead of brute-force Python cosine similarity.
    """

    __tablename__ = "document_embeddings"

    # Parent document
    document_id = Column(
        UUID(as_uuid=True),
        ForeignKey("documents.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )

    # Chunk information
    chunk_index = Column(Integer, nullable=False)
    chunk_text = Column(Text, nullable=False)

    # Vector embedding — native pgvector type with HNSW index (see migration 001)
    # Replaces the old JSONB column for 10-100x faster similarity search.
    embedding = Column(Vector(768), nullable=False)
    embedding_model = Column(String(100), default="text-embedding-004")

    # Context metadata — powers context-aware answers with page citations
    section_title = Column(String(500), nullable=True)  # e.g. "Safety Procedures"
    page_numbers = Column(
        JSONB, nullable=True
    )  # e.g. [12, 13] — pages this chunk spans

    # Legacy page columns (kept for backward compat, use page_numbers instead)
    start_page = Column(Integer, nullable=True)
    end_page = Column(Integer, nullable=True)

    extra_metadata = Column("metadata", JSONB, default=dict)

    # Relationships
    document = relationship("Document", back_populates="embeddings")

    def __repr__(self):
        return f"<DocumentEmbedding doc={self.document_id} chunk={self.chunk_index}>"