"""SQLite persistence for the legacy pilot and modality-revision study. Migrations are additive: the original ``exchanges`` and ``annotations`` rows remain exportable. New study items and their six predeclared condition tasks live in separate tables, so deploying a new manifest never resets pilot data. """ from __future__ import annotations import json import hashlib import os from datetime import datetime, timedelta, timezone from pathlib import Path from sqlalchemy import ( Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, create_engine, func, select, text, ) from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker DB_PATH = os.environ.get("ANNOTATOR_DB_PATH", "/data/annotator.db") Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) class Base(DeclarativeBase): pass def _utcnow() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) class Exchange(Base): """Legacy pilot item. Kept intact for backward-compatible export.""" __tablename__ = "exchanges" id: Mapped[str] = mapped_column(String, primary_key=True) call_id: Mapped[str] = mapped_column(String, index=True) exchange_index: Mapped[int] = mapped_column(Integer) year: Mapped[int | None] = mapped_column(Integer, nullable=True) question_text: Mapped[str] = mapped_column(Text) answer_text: Mapped[str] = mapped_column(Text) audio_clip_filename: Mapped[str] = mapped_column(String) duration_s: Mapped[float | None] = mapped_column(Integer, nullable=True) bootstrap_rasiah: Mapped[str | None] = mapped_column(String, nullable=True) flagged_broken: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) class Annotator(Base): __tablename__ = "annotators" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String, unique=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) current_exchange_id: Mapped[str | None] = mapped_column(ForeignKey("exchanges.id"), nullable=True) finance_familiarity: Mapped[str | None] = mapped_column(String, nullable=True) english_proficiency: Mapped[str | None] = mapped_column(String, nullable=True) consented_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) training_passed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) training_attempts: Mapped[int] = mapped_column(Integer, default=0) current_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True) class StudyItem(Base): __tablename__ = "study_items" id: Mapped[str] = mapped_column(String, primary_key=True) study_id: Mapped[str] = mapped_column(String, index=True) partition: Mapped[str] = mapped_column(String, index=True) call_id: Mapped[str] = mapped_column(String, index=True) exchange_index: Mapped[int] = mapped_column(Integer) question_text: Mapped[str] = mapped_column(Text) answer_text: Mapped[str] = mapped_column(Text) audio_filename: Mapped[str | None] = mapped_column(String, nullable=True) duration_s: Mapped[int | None] = mapped_column(Integer, nullable=True) episode_reconstructed: Mapped[bool] = mapped_column(Boolean, default=False) eligibility_warning: Mapped[str | None] = mapped_column(Text, nullable=True) active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) class StudyTask(Base): __tablename__ = "study_tasks" __table_args__ = (UniqueConstraint("item_id", "slot", name="uq_study_item_slot"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) item_id: Mapped[str] = mapped_column(ForeignKey("study_items.id"), index=True) condition: Mapped[str] = mapped_column(String, index=True) slot: Mapped[int] = mapped_column(Integer) display_order_seed: Mapped[int] = mapped_column(Integer) assigned_annotator_id: Mapped[int | None] = mapped_column(ForeignKey("annotators.id"), nullable=True, index=True) assigned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) class Annotation(Base): __tablename__ = "annotations" __table_args__ = ( UniqueConstraint("exchange_id", "annotator_id", "condition", name="uq_one_label_per_annotator"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) exchange_id: Mapped[str | None] = mapped_column(ForeignKey("exchanges.id"), nullable=True, index=True) annotator_id: Mapped[int] = mapped_column(ForeignKey("annotators.id"), index=True) condition: Mapped[str] = mapped_column(String, default="text_audio") idk: Mapped[bool] = mapped_column(Boolean, default=False) flagged_broken: Mapped[bool] = mapped_column(Boolean, default=False) flag_reason: Mapped[str | None] = mapped_column(String, nullable=True) rasiah: Mapped[str | None] = mapped_column(String, nullable=True) bavelas: Mapped[str | None] = mapped_column(String, nullable=True) bull: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) # Additive modality-revision fields. task_id: Mapped[int | None] = mapped_column(ForeignKey("study_tasks.id"), nullable=True, unique=True, index=True) study_item_id: Mapped[str | None] = mapped_column(ForeignKey("study_items.id"), nullable=True, index=True) gate: Mapped[str | None] = mapped_column(String, nullable=True) gate_confidence: Mapped[int | None] = mapped_column(Integer, nullable=True) responsiveness: Mapped[int | None] = mapped_column(Integer, nullable=True) supplied_information: Mapped[str | None] = mapped_column(String, nullable=True) confidence: Mapped[int | None] = mapped_column(Integer, nullable=True) delivery_descriptors_json: Mapped[str | None] = mapped_column(Text, nullable=True) audio_revision: Mapped[str | None] = mapped_column(String, nullable=True) audible_event: Mapped[str | None] = mapped_column(String, nullable=True) audible_event_other: Mapped[str | None] = mapped_column(Text, nullable=True) rationale: Mapped[str | None] = mapped_column(Text, nullable=True) response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) audio_played_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) audio_completed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) def _existing_columns(table_name: str) -> set[str]: with engine.connect() as connection: rows = connection.execute(text(f"PRAGMA table_info({table_name})")).mappings() return {str(row["name"]) for row in rows} def _add_column(table: str, name: str, ddl: str) -> None: if name in _existing_columns(table): return with engine.begin() as connection: connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")) def init_db() -> None: """Create new tables and add columns to an existing pilot database.""" Base.metadata.create_all(engine) for name, ddl in { "finance_familiarity": "VARCHAR", "english_proficiency": "VARCHAR", "consented_at": "DATETIME", "training_passed_at": "DATETIME", "training_attempts": "INTEGER DEFAULT 0", "current_task_id": "INTEGER", }.items(): _add_column("annotators", name, ddl) for name, ddl in { "task_id": "INTEGER", "study_item_id": "VARCHAR", "gate": "VARCHAR", "gate_confidence": "INTEGER", "responsiveness": "INTEGER", "supplied_information": "VARCHAR", "confidence": "INTEGER", "delivery_descriptors_json": "TEXT", "audio_revision": "VARCHAR", "audible_event": "VARCHAR", "audible_event_other": "TEXT", "rationale": "TEXT", "response_time_ms": "INTEGER", "audio_played_ms": "INTEGER", "audio_completed": "BOOLEAN", }.items(): _add_column("annotations", name, ddl) with engine.begin() as connection: connection.execute( text( "CREATE UNIQUE INDEX IF NOT EXISTS ux_annotations_task_id " "ON annotations(task_id) WHERE task_id IS NOT NULL" ) ) def get_session() -> Session: return SessionLocal() def get_annotator(session: Session, identity: str) -> Annotator | None: return session.execute( select(Annotator).where(Annotator.email == identity.strip().lower()) ).scalar_one_or_none() def enroll_annotator( session: Session, identity: str, finance_familiarity: str, english_proficiency: str, ) -> Annotator: identity = identity.strip().lower() row = get_annotator(session, identity) if row is None: row = Annotator(email=identity) session.add(row) session.flush() row.finance_familiarity = finance_familiarity row.english_proficiency = english_proficiency row.consented_at = row.consented_at or _utcnow() session.commit() session.refresh(row) return row def record_training_attempt(session: Session, annotator: Annotator, passed: bool) -> None: annotator.training_attempts = (annotator.training_attempts or 0) + 1 if passed: annotator.training_passed_at = _utcnow() session.commit() def seed_study(session: Session, manifest_path: Path, assignments_path: Path) -> dict: """Idempotently upsert study items/tasks; never delete or reset labels.""" if not manifest_path.exists() or not assignments_path.exists(): return {"items": 0, "tasks": 0, "warning": "study package missing"} manifest = json.loads(manifest_path.read_text()) items = manifest["items"] if isinstance(manifest, dict) else manifest assignments = json.loads(assignments_path.read_text()) for payload in items: row = session.get(StudyItem, payload["study_item_id"]) if row is None: row = StudyItem(id=payload["study_item_id"]) session.add(row) row.study_id = manifest.get("study_id", "modality_revision_v1") row.partition = payload["partition"] row.call_id = str(payload["call_id"]) row.exchange_index = int(payload["exchange_index"]) row.question_text = payload["question"] row.answer_text = payload["answer"] row.audio_filename = payload.get("audio_filename") row.duration_s = round(float(payload.get("duration_s") or 0)) row.episode_reconstructed = bool(payload.get("episode_reconstructed")) row.eligibility_warning = payload.get("eligibility_warning") row.active = bool(payload.get("active", True)) # Mirror the key into the legacy table because the original deployed # SQLite schema made annotations.exchange_id NOT NULL. Keeping this # additive avoids a destructive table rebuild during migration. legacy = session.get(Exchange, payload["study_item_id"]) if legacy is None: session.add( Exchange( id=payload["study_item_id"], call_id=str(payload["call_id"]), exchange_index=int(payload["exchange_index"]), year=None, question_text=payload["question"], answer_text=payload["answer"], audio_clip_filename=payload.get("audio_filename") or "", duration_s=round(float(payload.get("duration_s") or 0)), bootstrap_rasiah=None, ) ) session.flush() for payload in assignments: existing = session.execute( select(StudyTask).where( StudyTask.item_id == payload["study_item_id"], StudyTask.slot == int(payload["slot"]), ) ).scalar_one_or_none() if existing is None: session.add( StudyTask( item_id=payload["study_item_id"], condition=payload["condition"], slot=int(payload["slot"]), display_order_seed=int(payload["display_order_seed"]), ) ) elif not existing.completed: existing.condition = payload["condition"] existing.display_order_seed = int(payload["display_order_seed"]) session.commit() return { "items": session.scalar(select(func.count(StudyItem.id))) or 0, "tasks": session.scalar(select(func.count(StudyTask.id))) or 0, } def claim_task(session: Session, annotator: Annotator, phase: str) -> tuple[StudyTask, StudyItem] | None: """Claim one task while preventing cross-condition repeat exposure.""" if annotator.current_task_id: current = session.get(StudyTask, annotator.current_task_id) if current and not current.completed and current.assigned_annotator_id == annotator.id: item = session.get(StudyItem, current.item_id) if item: return current, item annotator.current_task_id = None session.commit() # Recover abandoned browser assignments after eight hours. expiry = _utcnow() - timedelta(hours=8) stale = session.scalars( select(StudyTask).where( StudyTask.completed.is_(False), StudyTask.assigned_at.is_not(None), StudyTask.assigned_at < expiry, ) ).all() for task in stale: owner = session.get(Annotator, task.assigned_annotator_id) if task.assigned_annotator_id else None if owner and owner.current_task_id == task.id: owner.current_task_id = None task.assigned_annotator_id = None task.assigned_at = None if stale: session.commit() seen_items = select(StudyTask.item_id).where(StudyTask.assigned_annotator_id == annotator.id) partitions = { "development": ["development"], "confirmatory": ["locked_confirmatory"], "all": ["development", "locked_confirmatory"], }.get(phase, ["development"]) statement = ( select(StudyTask, StudyItem) .join(StudyItem, StudyTask.item_id == StudyItem.id) .where( StudyTask.completed.is_(False), StudyTask.assigned_annotator_id.is_(None), StudyItem.active.is_(True), StudyItem.partition.in_(partitions), StudyTask.item_id.notin_(seen_items), ) .order_by(((StudyTask.display_order_seed + annotator.id * 7919) % 100000).asc()) .limit(1) ) result = session.execute(statement).first() if result is None: return None task, item = result task.assigned_annotator_id = annotator.id task.assigned_at = _utcnow() annotator.current_task_id = task.id session.commit() return task, item def complete_task(session: Session, annotator: Annotator, task_id: int, clean: dict) -> bool: task = session.get(StudyTask, task_id) if ( task is None or task.completed or task.assigned_annotator_id != annotator.id or annotator.current_task_id != task.id ): return False annotation = Annotation( exchange_id=task.item_id, annotator_id=annotator.id, condition=task.condition, task_id=task.id, study_item_id=task.item_id, **clean, ) session.add(annotation) task.completed = True annotator.current_task_id = None session.commit() return True def flag_task(session: Session, annotator: Annotator, task_id: int, reason: str) -> bool: task = session.get(StudyTask, task_id) if task is None or task.assigned_annotator_id != annotator.id or task.completed: return False session.add( Annotation( exchange_id=task.item_id, annotator_id=annotator.id, condition=task.condition, task_id=task.id, study_item_id=task.item_id, flagged_broken=True, flag_reason=reason[:500], ) ) task.completed = True annotator.current_task_id = None session.commit() return True def study_progress(session: Session, annotator_id: int, phase: str) -> dict: partitions = { "development": ["development"], "confirmatory": ["locked_confirmatory"], "all": ["development", "locked_confirmatory"], }.get(phase, ["development"]) total = session.scalar( select(func.count(StudyTask.id)) .join(StudyItem, StudyTask.item_id == StudyItem.id) .where(StudyItem.partition.in_(partitions), StudyItem.active.is_(True)) ) or 0 completed = session.scalar( select(func.count(StudyTask.id)) .join(StudyItem, StudyTask.item_id == StudyItem.id) .where( StudyItem.partition.in_(partitions), StudyItem.active.is_(True), StudyTask.completed.is_(True), ) ) or 0 own = session.scalar( select(func.count(StudyTask.id)).where( StudyTask.assigned_annotator_id == annotator_id, StudyTask.completed.is_(True), ) ) or 0 return {"own_done": own, "study_done": completed, "study_total": total} def export_payload(session: Session) -> dict: annotators = {row.id: row for row in session.scalars(select(Annotator)).all()} rows = [] for annotation in session.scalars(select(Annotation)).all(): annotator = annotators.get(annotation.annotator_id) identity = annotator.email if annotator else "unknown" salt = os.environ.get("ANNOTATOR_EXPORT_SALT") or os.environ.get("ANNOTATOR_SECRET", "local") participant_hash = hashlib.sha256(f"{salt}|{identity}".encode()).hexdigest()[:20] rows.append( { "annotation_id": annotation.id, "legacy_exchange_id": annotation.exchange_id, "study_item_id": annotation.study_item_id, "task_id": annotation.task_id, "participant_hash": participant_hash, "finance_familiarity": annotator.finance_familiarity if annotator else None, "english_proficiency": annotator.english_proficiency if annotator else None, "training_attempts": annotator.training_attempts if annotator else None, "training_passed_at": ( annotator.training_passed_at.isoformat() if annotator and annotator.training_passed_at else None ), "condition": annotation.condition, "idk": annotation.idk, "flagged_broken": annotation.flagged_broken, "flag_reason": annotation.flag_reason, "gate": annotation.gate, "gate_confidence": annotation.gate_confidence, "responsiveness": annotation.responsiveness, "rasiah": annotation.rasiah, "bavelas": annotation.bavelas, "bull": annotation.bull, "supplied_information": annotation.supplied_information, "confidence": annotation.confidence, "delivery_descriptors": ( json.loads(annotation.delivery_descriptors_json) if annotation.delivery_descriptors_json else None ), "audio_revision": annotation.audio_revision, "audible_event": annotation.audible_event, "audible_event_other": annotation.audible_event_other, "rationale": annotation.rationale, "response_time_ms": annotation.response_time_ms, "audio_played_ms": annotation.audio_played_ms, "audio_completed": annotation.audio_completed, "created_at": annotation.created_at.isoformat(), } ) return {"schema_version": "annotation-export-v2", "annotations": rows}