Spaces:
Sleeping
Sleeping
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}>"
|