secureshield-backend / db /database.py
Sharma7's picture
fix: pass other_suggestions to db and update schema
29c1fc5
Raw
History Blame Contribute Delete
19.5 kB
"""
PolicyEye Database Layer
Production: Supabase PostgreSQL (set DATABASE_URL in .env)
Dev fallback: SQLite via aiosqlite (auto-created if DATABASE_URL is not set)
All queries are fully async via SQLAlchemy ORM.
This module exposes the same async helper functions used across the codebase:
- init_db()
- save_policy(...)
- get_policy(policy_id)
- get_policy_by_hash(pdf_hash)
- get_all_policies()
- save_eligibility_check(...)
- get_check_history(limit)
It uses SQLAlchemy 2.0 async ORM so future migrations with Alembic are straightforward.
"""
import os
import json
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker, Mapped, mapped_column
from sqlalchemy import Integer, String, Float, Text, DateTime, func, select, UniqueConstraint
from core.config import DATABASE_PATH
logger = logging.getLogger(__name__)
# Database URL: prefer DATABASE_URL env var (production), fallback to SQLite (dev)
# Production: DATABASE_URL=postgresql+asyncpg://user:pass@host/dbname
_raw_db_url = os.getenv("DATABASE_URL")
if not _raw_db_url:
# Dev fallback: local SQLite file (auto-created, no setup needed)
import warnings
warnings.warn(
"DATABASE_URL not set — using local SQLite. "
"Set DATABASE_URL to a PostgreSQL connection string for production.",
stacklevel=2,
)
sqlite_path = os.path.join(os.path.dirname(__file__), "secureshield_dev.db")
DATABASE_URL = f"sqlite+aiosqlite:///{sqlite_path}"
else:
# Use make_url to properly handle special characters in passwords
from sqlalchemy.engine import make_url
DATABASE_URL = make_url(_raw_db_url)
# Async engine and session
# For Supabase Connection Pooler (pgbouncer in transaction mode),
# we must disable asyncpg's prepared statement cache.
_connect_args = {}
if _raw_db_url and "pooler.supabase.com" in _raw_db_url:
_connect_args = {
"statement_cache_size": 0,
"prepared_statement_cache_size": 0,
}
engine: AsyncEngine = create_async_engine(
DATABASE_URL, echo=False, future=True, connect_args=_connect_args
)
AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
class UserProfile(Base):
__tablename__ = "user_profiles"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
full_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
phone: Mapped[Optional[str]] = mapped_column(String, nullable=True)
dob: Mapped[Optional[str]] = mapped_column(String, nullable=True)
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
avatar_base64: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
updated_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class Policy(Base):
__tablename__ = "policies"
__table_args__ = (
UniqueConstraint("user_id", "raw_text_hash", name="uq_policy_user_hash"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
insurer: Mapped[str] = mapped_column(String, nullable=False)
plan_name: Mapped[str] = mapped_column(String, nullable=False)
sum_insured: Mapped[float] = mapped_column(Float, nullable=False)
policy_type: Mapped[str] = mapped_column(String, default="individual")
rules_json: Mapped[str] = mapped_column(Text, nullable=False)
raw_text_hash: Mapped[Optional[str]] = mapped_column(String, index=True, nullable=True)
pdf_storage_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
is_reviewed: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EligibilityCheck(Base):
__tablename__ = "eligibility_checks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
policy_id: Mapped[int] = mapped_column(Integer)
case_json: Mapped[str] = mapped_column(Text, nullable=False)
verdict_json: Mapped[str] = mapped_column(Text, nullable=False)
explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ChatThread(Base):
__tablename__ = "chat_threads"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
title: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
thread_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
role: Mapped[str] = mapped_column(String, nullable=False) # 'user' or 'assistant'
content: Mapped[str] = mapped_column(Text, nullable=False)
method: Mapped[Optional[str]] = mapped_column(String, nullable=True)
duration_ms: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AppFeedback(Base):
__tablename__ = "app_feedback"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[str]] = mapped_column(String, index=True, nullable=True)
accuracy_rating: Mapped[int] = mapped_column(Integer, nullable=False)
claim_rejections: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
problem_solving: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
addon_suggestions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
knowledge_source: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
govt_regulations: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
developer_centric: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
other_suggestions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
from sqlalchemy.exc import IntegrityError, ProgrammingError
async def init_db():
"""Create tables if they don't exist (async)."""
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("[Database] Async ORM tables initialized")
except (IntegrityError, ProgrammingError) as e:
logger.warning(f"[Database] Race condition during table creation, ignoring: {e}")
async def save_policy(insurer: str, plan_name: str, sum_insured: float,
policy_type: str, rules: List[dict], raw_text_hash: Optional[str],
pdf_storage_url: Optional[str] = None,
user_id: str = "") -> int:
"""Save an ingested policy and return its ID."""
async with AsyncSessionLocal() as session:
policy = Policy(
user_id=user_id,
insurer=insurer,
plan_name=plan_name,
sum_insured=sum_insured,
policy_type=policy_type,
rules_json=json.dumps(rules),
raw_text_hash=raw_text_hash,
pdf_storage_url=pdf_storage_url,
)
session.add(policy)
await session.commit()
await session.refresh(policy)
logger.info(f"[Database] Saved policy #{policy.id}: {insurer} - {plan_name} (user: {user_id[:8]}...)")
return policy.id
async def save_user_profile(user_id: str, full_name: str, phone: str, dob: str, address: str, avatar_base64: str) -> bool:
"""Upsert user profile."""
async with AsyncSessionLocal() as session:
stmt = select(UserProfile).where(UserProfile.user_id == user_id).limit(1)
result = await session.execute(stmt)
profile = result.scalar_one_or_none()
if profile:
profile.full_name = full_name
profile.phone = phone
profile.dob = dob
profile.address = address
if avatar_base64 is not None:
profile.avatar_base64 = avatar_base64
else:
profile = UserProfile(
user_id=user_id,
full_name=full_name,
phone=phone,
dob=dob,
address=address,
avatar_base64=avatar_base64
)
session.add(profile)
await session.commit()
logger.info(f"[Database] Saved user profile for {user_id[:8]}...")
return True
async def get_user_profile(user_id: str) -> Optional[dict]:
"""Retrieve user profile by ID."""
async with AsyncSessionLocal() as session:
stmt = select(UserProfile).where(UserProfile.user_id == user_id).limit(1)
result = await session.execute(stmt)
profile = result.scalar_one_or_none()
if not profile:
return None
return {
"user_id": profile.user_id,
"full_name": profile.full_name,
"phone": profile.phone,
"dob": profile.dob,
"address": profile.address,
"avatar_base64": profile.avatar_base64,
}
async def get_policy(policy_id: int, user_id: str = "") -> Optional[dict]:
"""Retrieve a policy by ID, scoped to user if user_id is provided."""
async with AsyncSessionLocal() as session:
if user_id:
stmt = select(Policy).where(Policy.id == policy_id, Policy.user_id == user_id).limit(1)
result = await session.execute(stmt)
policy = result.scalar_one_or_none()
else:
policy = await session.get(Policy, policy_id)
if not policy:
return None
return {
"id": policy.id,
"user_id": policy.user_id,
"insurer": policy.insurer,
"plan_name": policy.plan_name,
"sum_insured": policy.sum_insured,
"policy_type": policy.policy_type,
"rules": json.loads(policy.rules_json),
"raw_text_hash": policy.raw_text_hash,
"pdf_storage_url": policy.pdf_storage_url,
"is_reviewed": policy.is_reviewed,
"created_at": str(policy.created_at),
}
async def get_policy_by_hash(pdf_hash: str, user_id: str = "") -> Optional[dict]:
"""Retrieve a policy by PDF hash, optionally scoped to a specific user."""
async with AsyncSessionLocal() as session:
stmt = select(Policy).where(Policy.raw_text_hash == pdf_hash)
if user_id:
stmt = stmt.where(Policy.user_id == user_id)
result = await session.execute(stmt.limit(1))
policy_obj = result.scalar_one_or_none()
if not policy_obj:
return None
return {
"id": policy_obj.id,
"user_id": policy_obj.user_id,
"insurer": policy_obj.insurer,
"plan_name": policy_obj.plan_name,
"sum_insured": policy_obj.sum_insured,
"policy_type": policy_obj.policy_type,
"rules": json.loads(policy_obj.rules_json or "[]"),
"raw_text_hash": policy_obj.raw_text_hash,
"pdf_storage_url": policy_obj.pdf_storage_url,
"is_reviewed": policy_obj.is_reviewed,
"created_at": str(policy_obj.created_at),
}
async def get_all_policies(user_id: str = "") -> List[dict]:
"""Retrieve all ingested policies for a specific user (without full rules for listing)."""
async with AsyncSessionLocal() as session:
stmt = select(
Policy.id,
Policy.insurer,
Policy.plan_name,
Policy.sum_insured,
Policy.policy_type,
Policy.is_reviewed,
Policy.created_at,
).order_by(Policy.created_at.desc())
if user_id:
from sqlalchemy import or_
stmt = stmt.where(or_(Policy.user_id == user_id, Policy.user_id == "api_client"))
q = await session.execute(stmt)
rows = q.fetchall()
return [
{
"id": r.id,
"insurer": r.insurer,
"plan_name": r.plan_name,
"sum_insured": r.sum_insured,
"policy_type": r.policy_type,
"is_reviewed": r.is_reviewed,
"created_at": str(r.created_at),
}
for r in rows
]
async def save_eligibility_check(policy_id: int, case_json: str,
verdict_json: str, explanation: Optional[str],
user_id: str = "") -> int:
"""Save an eligibility check result."""
async with AsyncSessionLocal() as session:
check = EligibilityCheck(
user_id=user_id,
policy_id=policy_id,
case_json=case_json,
verdict_json=verdict_json,
explanation=explanation,
)
session.add(check)
await session.commit()
await session.refresh(check)
return check.id
async def get_check_history(limit: int = 20, user_id: str = "") -> List[dict]:
"""Retrieve recent eligibility check history for a specific user."""
async with AsyncSessionLocal() as session:
stmt = select(
EligibilityCheck.id,
EligibilityCheck.policy_id,
EligibilityCheck.case_json,
EligibilityCheck.verdict_json,
EligibilityCheck.explanation,
EligibilityCheck.created_at,
).order_by(EligibilityCheck.created_at.desc()).limit(limit)
if user_id:
stmt = stmt.where(EligibilityCheck.user_id == user_id)
q = await session.execute(stmt)
rows = q.fetchall()
return [
{
"id": r.id,
"policy_id": r.policy_id,
"case_json": r.case_json,
"verdict_json": r.verdict_json,
"explanation": r.explanation,
"created_at": str(r.created_at),
}
for r in rows
]
async def create_chat_thread(user_id: str, title: str) -> int:
"""Create a new chat thread."""
async with AsyncSessionLocal() as session:
thread = ChatThread(user_id=user_id, title=title)
session.add(thread)
await session.commit()
await session.refresh(thread)
return thread.id
async def get_chat_threads(user_id: str) -> List[dict]:
"""Get all chat threads for a user."""
async with engine.begin() as conn:
result = await conn.execute(
select(ChatThread)
.where(ChatThread.user_id == user_id)
.order_by(ChatThread.created_at.desc())
)
rows = result.fetchall()
return [{"id": r.id, "title": r.title, "created_at": str(r.created_at)} for r in rows]
async def delete_chat_thread(thread_id: int):
"""Delete a chat thread and its messages."""
from sqlalchemy import delete
async with engine.begin() as conn:
await conn.execute(
delete(ChatThread)
.where(ChatThread.id == thread_id)
)
async def save_chat_message(thread_id: int, role: str, content: str, method: Optional[str] = None, duration_ms: Optional[float] = None) -> int:
"""Save a chat message to a thread."""
async with AsyncSessionLocal() as session:
msg = ChatMessage(thread_id=thread_id, role=role, content=content, method=method, duration_ms=duration_ms)
session.add(msg)
await session.commit()
await session.refresh(msg)
return msg.id
async def get_chat_messages(thread_id: int) -> List[dict]:
"""Get all messages for a thread."""
async with AsyncSessionLocal() as session:
stmt = select(ChatMessage).where(ChatMessage.thread_id == thread_id).order_by(ChatMessage.created_at.asc())
q = await session.execute(stmt)
rows = q.scalars().all()
return [{
"id": r.id,
"role": r.role,
"content": r.content,
"method": r.method,
"duration_ms": r.duration_ms,
"created_at": str(r.created_at)
} for r in rows]
async def clear_policies_and_checks() -> None:
"""Delete all rows from `eligibility_checks` and `policies` tables.
Useful for tests and demo reset. Uses SQLAlchemy ORM (database-agnostic).
"""
from sqlalchemy import delete as sa_delete
async with AsyncSessionLocal() as session:
await session.execute(sa_delete(EligibilityCheck))
await session.execute(sa_delete(Policy))
await session.commit()
logger.info("[Database] Cleared all policies and checks.")
async def delete_user_data(user_id: str) -> dict:
"""
Delete all data associated with a user_id to comply with DPDPA 2023
'Right to Erasure' for sensitive health data.
"""
from sqlalchemy import delete as sa_delete
async with AsyncSessionLocal() as session:
# Delete profile
profile_res = await session.execute(sa_delete(UserProfile).where(UserProfile.user_id == user_id))
# Delete eligibility checks
checks_res = await session.execute(sa_delete(EligibilityCheck).where(EligibilityCheck.user_id == user_id))
# Delete policies
# Note: We keep the LLM cache (llm_cache table) since it's anonymous and hashed
policies_res = await session.execute(sa_delete(Policy).where(Policy.user_id == user_id))
# Delete chat threads (cascade should delete messages, but let's be explicit if needed)
threads_res = await session.execute(sa_delete(ChatThread).where(ChatThread.user_id == user_id))
await session.commit()
counts = {
"profile": profile_res.rowcount,
"checks": checks_res.rowcount,
"policies": policies_res.rowcount,
"chat_threads": threads_res.rowcount,
}
logger.info(f"[Database] Deleted user data for {user_id}: {counts}")
return counts
async def save_app_feedback(
user_id: str,
accuracy_rating: int,
claim_rejections: str,
problem_solving: str,
addon_suggestions: str,
knowledge_source: str,
govt_regulations: str,
developer_centric: str,
other_suggestions: str = ""
) -> int:
"""Save user app feedback."""
async with AsyncSessionLocal() as session:
feedback = AppFeedback(
user_id=user_id or None,
accuracy_rating=accuracy_rating,
claim_rejections=claim_rejections,
problem_solving=problem_solving,
addon_suggestions=addon_suggestions,
knowledge_source=knowledge_source,
govt_regulations=govt_regulations,
developer_centric=developer_centric,
other_suggestions=other_suggestions
)
session.add(feedback)
await session.commit()
await session.refresh(feedback)
logger.info(f"[Database] Saved app feedback from user: {user_id}")
return feedback.id