from __future__ import annotations import json import sqlite3 from collections.abc import Iterator from contextlib import contextmanager from datetime import datetime from typing import Any from uuid import UUID, uuid4 from src.domain.contracts import ( AgentJobErrorCategory, AgentJobStatus, AgentJobSummary, AgentJobType, ContentReviewKind, ContentReviewIssueSummary, ContentReviewReportSummary, ContentReviewSuggestionSummary, AssetRevisionSummary, AssetStatus, AssetSummary, AssetType, ArticleSummary, BoundaryQuestionSummary, ClaimRiskLevel, ClaimSupportStatus, ClaimSummary, DraftFaqItem, DraftSummary, EvidenceSummary, PlanReviewStatus, PlanSectionSummary, PlanSummary, PublishingRules, PublishingStatus, ResearchArtifactManifestSummary, ResearchArtifactSummary, ArticleWorkflowStatus, ReviewSuggestionStatus, WorkflowEventSummary, Role, ScriptConfigVersionStatus, TargetSiteConfig, UserSummary, ) from src.infrastructure.schema import setup_database JsonObject = dict[str, Any] def open_backend_repository(dsn: str) -> "BackendRepository": return BackendRepository(dsn) class BackendRepository: def __init__(self, dsn: str) -> None: self.dsn = dsn self.dialect = "sqlite" if dsn.startswith("sqlite:///") else "postgres" self.users = UsersRepository(self) self.target_sites = TargetSitesRepository(self) self.articles = ArticlesRepository(self) self.boundary_questions = BoundaryQuestionsRepository(self) self.article_plans = ArticlePlansRepository(self) self.article_drafts = ArticleDraftsRepository(self) self.content_reviews = ContentReviewsRepository(self) self.assets = AssetsRepository(self) self.research_manifests = ResearchManifestsRepository(self) self.evidence_items = EvidenceItemsRepository(self) self.claims = ClaimsRepository(self) self.agent_jobs = AgentJobsRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self) self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self) self.schema = SchemaRepository(self) def setup(self) -> None: setup_database(self.dsn) @contextmanager def connection(self) -> Iterator[Any]: connection = self._connect() try: with connection: yield connection finally: connection.close() def _connect(self) -> Any: if self.dialect == "sqlite": sqlite_path = self.dsn.removeprefix("sqlite:///") connection = sqlite3.connect(sqlite_path) connection.row_factory = sqlite3.Row return connection import psycopg from psycopg.rows import dict_row return psycopg.connect(self.dsn, row_factory=dict_row) def placeholder(self) -> str: if self.dialect == "sqlite": return "?" return "%s" def json_cast(self) -> str: if self.dialect == "sqlite": return "" return "::jsonb" class UsersRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def upsert( self, *, user_id: UUID, email: str, display_name: str, role: Role, created_at: datetime, updated_at: datetime, ) -> UserSummary: placeholder = self._repository.placeholder() sql = f""" INSERT INTO users ( id, email, display_name, role, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) ON CONFLICT (email) DO UPDATE SET display_name = excluded.display_name, role = excluded.role, updated_at = excluded.updated_at """ params = ( str(user_id), email, display_name, role.value, _datetime_value(created_at), _datetime_value(updated_at), ) with self._repository.connection() as connection: connection.execute(sql, params) return self.get_by_email(email) def get_by_email(self, email: str) -> UserSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, display_name, role FROM users WHERE email = {placeholder} """, (email,), ).fetchone() if row is None: raise LookupError(f"User not found: {email}") return _user_from_row(row) def list(self) -> list[UserSummary]: with self._repository.connection() as connection: rows = connection.execute( """ SELECT id, display_name, role FROM users ORDER BY email """ ).fetchall() return [_user_from_row(row) for row in rows] def count_by_email(self, email: str) -> int: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f"SELECT COUNT(*) AS count FROM users WHERE email = {placeholder}", (email,), ).fetchone() return int(_row_value(row, "count")) class TargetSitesRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def upsert( self, *, site_id: UUID, name: str, slug: str, publishing_type: str, default_language: str, brand_voice: str, audience: str, seo_rules: JsonObject, visual_rules: JsonObject, source_rules: JsonObject, publishing_rules: PublishingRules, active_script_config_version_id: UUID | None, created_at: datetime, updated_at: datetime, ) -> TargetSiteConfig: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() sql = f""" INSERT INTO target_sites ( id, name, slug, publishing_type, default_language, brand_voice, audience, seo_rules, visual_rules, source_rules, publishing_rules, active_script_config_version_id, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder} ) ON CONFLICT (slug) DO UPDATE SET name = excluded.name, publishing_type = excluded.publishing_type, default_language = excluded.default_language, brand_voice = excluded.brand_voice, audience = excluded.audience, seo_rules = excluded.seo_rules, visual_rules = excluded.visual_rules, source_rules = excluded.source_rules, publishing_rules = excluded.publishing_rules, active_script_config_version_id = excluded.active_script_config_version_id, updated_at = excluded.updated_at """ params = ( str(site_id), name, slug, publishing_type, default_language, brand_voice, audience, _json_value(seo_rules), _json_value(visual_rules), _json_value(source_rules), _json_value(publishing_rules.model_dump(mode="json")), _uuid_value(active_script_config_version_id), _datetime_value(created_at), _datetime_value(updated_at), ) with self._repository.connection() as connection: connection.execute(sql, params) return self.get_by_slug(slug) def get_by_slug(self, slug: str) -> TargetSiteConfig: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, name, slug, publishing_type, default_language, brand_voice, audience, seo_rules, visual_rules, source_rules, publishing_rules, active_script_config_version_id, created_at, updated_at FROM target_sites WHERE slug = {placeholder} """, (slug,), ).fetchone() if row is None: raise LookupError(f"Target site not found: {slug}") return _target_site_from_row(row) def get_by_id(self, site_id: UUID) -> TargetSiteConfig: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, name, slug, publishing_type, default_language, brand_voice, audience, seo_rules, visual_rules, source_rules, publishing_rules, active_script_config_version_id, created_at, updated_at FROM target_sites WHERE id = {placeholder} """, (str(site_id),), ).fetchone() if row is None: raise LookupError(f"Target site not found: {site_id}") return _target_site_from_row(row) def update( self, *, site_id: UUID, name: str, slug: str, publishing_type: str, default_language: str, brand_voice: str, audience: str, seo_rules: JsonObject, visual_rules: JsonObject, source_rules: JsonObject, publishing_rules: PublishingRules, active_script_config_version_id: UUID | None, updated_at: datetime, ) -> TargetSiteConfig: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" UPDATE target_sites SET name = {placeholder}, slug = {placeholder}, publishing_type = {placeholder}, default_language = {placeholder}, brand_voice = {placeholder}, audience = {placeholder}, seo_rules = {placeholder}{json_cast}, visual_rules = {placeholder}{json_cast}, source_rules = {placeholder}{json_cast}, publishing_rules = {placeholder}{json_cast}, active_script_config_version_id = {placeholder}, updated_at = {placeholder} WHERE id = {placeholder} """, ( name, slug, publishing_type, default_language, brand_voice, audience, _json_value(seo_rules), _json_value(visual_rules), _json_value(source_rules), _json_value(publishing_rules.model_dump(mode="json")), _uuid_value(active_script_config_version_id), _datetime_value(updated_at), str(site_id), ), ) return self.get_by_id(site_id) def list(self) -> list[TargetSiteConfig]: with self._repository.connection() as connection: rows = connection.execute( """ SELECT id, name, slug, publishing_type, default_language, brand_voice, audience, seo_rules, visual_rules, source_rules, publishing_rules, active_script_config_version_id, created_at, updated_at FROM target_sites ORDER BY slug """ ).fetchall() return [_target_site_from_row(row) for row in rows] class ArticlesRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, target_site_id: UUID, status: ArticleWorkflowStatus, publishing_status: PublishingStatus, brief_description: str, working_title: str | None, language: str, content_type: str, primary_keyword: str | None, assigned_editor_id: UUID | None, created_at: datetime, updated_at: datetime, ) -> ArticleSummary: article_id = uuid4() placeholder = self._repository.placeholder() sql = f""" INSERT INTO articles ( id, target_site_id, status, publishing_status, brief_description, working_title, language, content_type, primary_keyword, assigned_editor_id, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) """ with self._repository.connection() as connection: connection.execute( sql, ( str(article_id), str(target_site_id), status.value, publishing_status.value, brief_description, working_title, language, content_type, primary_keyword, _uuid_value(assigned_editor_id), _datetime_value(created_at), _datetime_value(updated_at), ), ) return self.get(article_id) def list(self) -> list[ArticleSummary]: with self._repository.connection() as connection: rows = connection.execute( """ SELECT id, target_site_id, status, publishing_status, brief_description, working_title, language, content_type, primary_keyword, assigned_editor_id, created_at, updated_at FROM articles ORDER BY updated_at DESC """ ).fetchall() return [_article_summary_from_row(row) for row in rows] def get(self, article_id: UUID) -> ArticleSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, target_site_id, status, publishing_status, brief_description, working_title, language, content_type, primary_keyword, assigned_editor_id, created_at, updated_at FROM articles WHERE id = {placeholder} """, (str(article_id),), ).fetchone() if row is None: raise LookupError(f"Article not found: {article_id}") return _article_summary_from_row(row) def update_status( self, *, article_id: UUID, status: ArticleWorkflowStatus, updated_at: datetime, ) -> ArticleSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE articles SET status = {placeholder}, updated_at = {placeholder} WHERE id = {placeholder} """, (status.value, _datetime_value(updated_at), str(article_id)), ) return self.get(article_id) def create_workflow_event( self, *, article_id: UUID, event_type: str, from_status: ArticleWorkflowStatus | None, to_status: ArticleWorkflowStatus | None, actor_user_id: UUID | None, payload: dict[str, Any], created_at: datetime, ) -> None: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() sql = f""" INSERT INTO workflow_events ( id, article_id, event_type, from_status, to_status, actor_user_id, payload, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder} ) """ with self._repository.connection() as connection: connection.execute( sql, ( str(uuid4()), str(article_id), event_type, _article_status_value(from_status), _article_status_value(to_status), _uuid_value(actor_user_id), _json_value(payload), _datetime_value(created_at), ), ) def list_workflow_events(self, article_id: UUID) -> list[WorkflowEventSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, event_type, from_status, to_status, actor_user_id, payload, created_at FROM workflow_events WHERE article_id = {placeholder} ORDER BY created_at """, (str(article_id),), ).fetchall() return [_workflow_event_from_row(row) for row in rows] class BoundaryQuestionsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def replace_for_article( self, *, article_id: UUID, questions: list[JsonObject], created_at: datetime, ) -> list[BoundaryQuestionSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f"DELETE FROM boundary_questions WHERE article_id = {placeholder}", (str(article_id),), ) for question in questions: connection.execute( f""" INSERT INTO boundary_questions ( id, article_id, sort_order, category, question, answer, is_required, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) """, ( str(uuid4()), str(article_id), question["sort_order"], question["category"], question["question"], question.get("answer"), _bool_value(question.get("is_required", True)), _datetime_value(created_at), _datetime_value(created_at), ), ) return self.list_for_article(article_id) def list_for_article(self, article_id: UUID) -> list[BoundaryQuestionSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, sort_order, category, question, answer, is_required, created_at, updated_at FROM boundary_questions WHERE article_id = {placeholder} ORDER BY sort_order """, (str(article_id),), ).fetchall() return [_boundary_question_from_row(row) for row in rows] def get(self, *, article_id: UUID, question_id: UUID) -> BoundaryQuestionSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, sort_order, category, question, answer, is_required, created_at, updated_at FROM boundary_questions WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(question_id)), ).fetchone() if row is None: raise LookupError(f"Boundary question not found: {question_id}") return _boundary_question_from_row(row) def update_answer( self, *, article_id: UUID, question_id: UUID, answer: str | None, updated_at: datetime, ) -> BoundaryQuestionSummary: self.get(article_id=article_id, question_id=question_id) placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE boundary_questions SET answer = {placeholder}, updated_at = {placeholder} WHERE article_id = {placeholder} AND id = {placeholder} """, ( answer, _datetime_value(updated_at), str(article_id), str(question_id), ), ) return self.get(article_id=article_id, question_id=question_id) class ArticlePlansRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create_version( self, *, article_id: UUID, version: int, status: PlanReviewStatus, title_options: list[str], recommended_title: str | None, reader_persona: str | None, search_intent: str | None, thesis: str | None, claims_to_prove: list[str], evidence_needs: list[str], visual_needs: list[str], seo_notes: list[str], source_requirements: list[str], excluded_sources: list[str], tone: str | None, audience: str | None, risks: list[str], sections: list[JsonObject], created_at: datetime, ) -> PlanSummary: plan_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO article_plans ( id, article_id, version, status, title_options, recommended_title, reader_persona, search_intent, thesis, claims_to_prove, evidence_needs, visual_needs, seo_notes, source_requirements, excluded_sources, tone, audience, risks, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder} ) """, ( str(plan_id), str(article_id), version, status.value, _json_value(title_options), recommended_title, reader_persona, search_intent, thesis, _json_value(claims_to_prove), _json_value(evidence_needs), _json_value(visual_needs), _json_value(seo_notes), _json_value(source_requirements), _json_value(excluded_sources), tone, audience, _json_value(risks), _datetime_value(created_at), ), ) for index, section in enumerate(sections, start=1): connection.execute( f""" INSERT INTO plan_sections ( id, article_plan_id, sort_order, heading, purpose, key_points, evidence_needs, claims_to_support, target_word_count ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder} ) """, ( str(uuid4()), str(plan_id), index, section["heading"], section.get("purpose"), _json_value(section.get("key_points", [])), _json_value(section.get("evidence_needs", [])), _json_value(section.get("claims_to_support", [])), section.get("target_word_count"), ), ) return self.get(article_id=article_id, plan_id=plan_id) def list_for_article(self, article_id: UUID) -> list[PlanSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT {self._select_columns()} FROM article_plans WHERE article_id = {placeholder} ORDER BY version """, (str(article_id),), ).fetchall() return [self._with_sections(row) for row in rows] def get(self, *, article_id: UUID, plan_id: UUID) -> PlanSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT {self._select_columns()} FROM article_plans WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(plan_id)), ).fetchone() if row is None: raise LookupError(f"Plan not found: {plan_id}") return self._with_sections(row) def latest_version(self, article_id: UUID) -> int: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT COALESCE(MAX(version), 0) AS version FROM article_plans WHERE article_id = {placeholder} """, (str(article_id),), ).fetchone() return int(_row_value(row, "version")) def update_status( self, *, article_id: UUID, plan_id: UUID, status: PlanReviewStatus, ) -> PlanSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE article_plans SET status = {placeholder} WHERE article_id = {placeholder} AND id = {placeholder} """, (status.value, str(article_id), str(plan_id)), ) return self.get(article_id=article_id, plan_id=plan_id) def _with_sections(self, row: Any) -> PlanSummary: plan_id = _row_value(row, "id") placeholder = self._repository.placeholder() with self._repository.connection() as connection: section_rows = connection.execute( f""" SELECT id, article_plan_id, sort_order, heading, purpose, key_points, evidence_needs, claims_to_support, target_word_count FROM plan_sections WHERE article_plan_id = {placeholder} ORDER BY sort_order """, (str(plan_id),), ).fetchall() return _plan_summary_from_row(row, [_plan_section_from_row(item) for item in section_rows]) def _select_columns(self) -> str: return """ id, article_id, version, status, title_options, recommended_title, reader_persona, search_intent, thesis, claims_to_prove, evidence_needs, visual_needs, seo_notes, source_requirements, excluded_sources, tone, audience, risks, created_at """ class ArticleDraftsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create_version( self, *, article_id: UUID, version: int, title: str, slug: str, meta_title: str | None, meta_description: str | None, body_object_key: str | None, body_markdown: str, faq_items: list[JsonObject], visual_placeholders: list[str], evidence_references: list[UUID], unsupported_claim_warnings: list[str], based_on_draft_id: UUID | None, status: ArticleWorkflowStatus, created_at: datetime, updated_at: datetime, ) -> DraftSummary: draft_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO article_drafts ( id, article_id, version, title, slug, meta_title, meta_description, body_object_key, body_markdown, faq_items, visual_placeholders, evidence_references, unsupported_claim_warnings, based_on_draft_id, status, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) """, ( str(draft_id), str(article_id), version, title, slug, meta_title, meta_description, body_object_key, body_markdown, _json_value(faq_items), _json_value(visual_placeholders), _json_value([str(item_id) for item_id in evidence_references]), _json_value(unsupported_claim_warnings), _uuid_value(based_on_draft_id), status.value, _datetime_value(created_at), _datetime_value(updated_at), ), ) return self.get(article_id=article_id, draft_id=draft_id) def list_for_article(self, article_id: UUID) -> list[DraftSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT {self._select_columns()} FROM article_drafts WHERE article_id = {placeholder} ORDER BY version DESC """, (str(article_id),), ).fetchall() return [_draft_summary_from_row(row) for row in rows] def get(self, *, article_id: UUID, draft_id: UUID) -> DraftSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT {self._select_columns()} FROM article_drafts WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(draft_id)), ).fetchone() if row is None: raise LookupError(f"Draft not found: {draft_id}") return _draft_summary_from_row(row) def latest_for_article(self, article_id: UUID) -> DraftSummary | None: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT {self._select_columns()} FROM article_drafts WHERE article_id = {placeholder} ORDER BY version DESC LIMIT 1 """, (str(article_id),), ).fetchone() if row is None: return None return _draft_summary_from_row(row) def latest_version(self, article_id: UUID) -> int: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT COALESCE(MAX(version), 0) AS version FROM article_drafts WHERE article_id = {placeholder} """, (str(article_id),), ).fetchone() return int(_row_value(row, "version")) def _select_columns(self) -> str: return """ id, article_id, version, title, slug, meta_title, meta_description, body_object_key, body_markdown, faq_items, visual_placeholders, evidence_references, unsupported_claim_warnings, based_on_draft_id, status, created_at, updated_at """ class ContentReviewsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create_report( self, *, article_id: UUID, review_kind: ContentReviewKind, draft_id: UUID, score: int, recommended_slug: str, recommended_title: str, schema_json: JsonObject, rules_snapshot: JsonObject, created_at: datetime, ) -> ContentReviewReportSummary: report_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO article_review_reports ( id, article_id, review_kind, draft_id, score, issues, recommended_slug, recommended_title, schema_json, rules_snapshot, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder} ) """, ( str(report_id), str(article_id), review_kind.value, str(draft_id), score, _json_value([]), recommended_slug, recommended_title, _json_value(schema_json), _json_value(rules_snapshot), _datetime_value(created_at), ), ) return self.get_report(article_id=article_id, report_id=report_id) def list_reports_for_article( self, *, article_id: UUID, review_kind: ContentReviewKind, ) -> list[ContentReviewReportSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, review_kind, draft_id, score, recommended_slug, recommended_title, schema_json, rules_snapshot, created_at FROM article_review_reports WHERE article_id = {placeholder} AND review_kind = {placeholder} ORDER BY created_at DESC """, (str(article_id), review_kind.value), ).fetchall() return [self.get_report(article_id=article_id, report_id=_row_value(row, "id")) for row in rows] def latest_report( self, *, article_id: UUID, review_kind: ContentReviewKind, ) -> ContentReviewReportSummary | None: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id FROM article_review_reports WHERE article_id = {placeholder} AND review_kind = {placeholder} ORDER BY created_at DESC LIMIT 1 """, (str(article_id), review_kind.value), ).fetchone() if row is None: return None return self.get_report(article_id=article_id, report_id=_row_value(row, "id")) def get_report(self, *, article_id: UUID, report_id: UUID) -> ContentReviewReportSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, review_kind, draft_id, score, recommended_slug, recommended_title, schema_json, rules_snapshot, created_at FROM article_review_reports WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(report_id)), ).fetchone() if row is None: raise LookupError(f"Content review report not found: {report_id}") suggestions = self.list_suggestions_for_report( article_id=article_id, report_id=report_id, ) issues = [ ContentReviewIssueSummary( id=item.suggestion_key, suggestion_id=item.id, severity=item.severity, location=item.location, message=item.message, suggested_fix=item.suggested_fix, suggested_rewrite=item.suggested_rewrite, status=item.status, ) for item in suggestions ] return _content_review_report_from_row(row, issues=issues) def upsert_suggestion( self, *, suggestion_id: UUID, article_id: UUID, review_kind: ContentReviewKind, report_id: UUID, suggestion_key: str, severity: str, location: str, message: str, suggested_fix: str | None, suggested_rewrite: str | None, patch: JsonObject, created_at: datetime, updated_at: datetime, ) -> ContentReviewSuggestionSummary: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO article_review_suggestions ( id, article_id, review_kind, report_id, suggestion_key, severity, location, message, suggested_fix, suggested_rewrite, patch, status, applied_text, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) ON CONFLICT (article_id, review_kind, suggestion_key) DO UPDATE SET report_id = excluded.report_id, severity = excluded.severity, location = excluded.location, message = excluded.message, suggested_fix = excluded.suggested_fix, suggested_rewrite = excluded.suggested_rewrite, patch = excluded.patch, status = CASE WHEN article_review_suggestions.status = 'PENDING' THEN excluded.status ELSE article_review_suggestions.status END, applied_text = CASE WHEN article_review_suggestions.status = 'PENDING' THEN excluded.applied_text ELSE article_review_suggestions.applied_text END, updated_at = excluded.updated_at """, ( str(suggestion_id), str(article_id), review_kind.value, str(report_id), suggestion_key, severity, location, message, suggested_fix, suggested_rewrite, _json_value(patch), ReviewSuggestionStatus.PENDING.value, None, _datetime_value(created_at), _datetime_value(updated_at), ), ) return self.get_suggestion( article_id=article_id, review_kind=review_kind, suggestion_id=suggestion_id, ) def get_suggestion( self, *, article_id: UUID, review_kind: ContentReviewKind, suggestion_id: UUID, ) -> ContentReviewSuggestionSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, review_kind, report_id, suggestion_key, severity, location, message, suggested_fix, suggested_rewrite, patch, status, applied_text, created_at, updated_at FROM article_review_suggestions WHERE article_id = {placeholder} AND review_kind = {placeholder} AND id = {placeholder} """, ( str(article_id), review_kind.value, str(suggestion_id), ), ).fetchone() if row is None: raise LookupError(f"Content review suggestion not found: {suggestion_id}") return _content_review_suggestion_from_row(row) def list_suggestions_for_report( self, *, article_id: UUID, report_id: UUID, ) -> list[ContentReviewSuggestionSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, review_kind, report_id, suggestion_key, severity, location, message, suggested_fix, suggested_rewrite, patch, status, applied_text, created_at, updated_at FROM article_review_suggestions WHERE article_id = {placeholder} AND report_id = {placeholder} ORDER BY suggestion_key """, (str(article_id), str(report_id)), ).fetchall() return [_content_review_suggestion_from_row(row) for row in rows] def list_unresolved_for_article( self, *, article_id: UUID, ) -> list[ContentReviewSuggestionSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, review_kind, report_id, suggestion_key, severity, location, message, suggested_fix, suggested_rewrite, patch, status, applied_text, created_at, updated_at FROM article_review_suggestions WHERE article_id = {placeholder} AND status = {placeholder} ORDER BY review_kind, suggestion_key """, (str(article_id), ReviewSuggestionStatus.PENDING.value), ).fetchall() return [_content_review_suggestion_from_row(row) for row in rows] def update_suggestion( self, *, article_id: UUID, review_kind: ContentReviewKind, suggestion_id: UUID, status: ReviewSuggestionStatus, applied_text: str | None, updated_at: datetime, ) -> ContentReviewSuggestionSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: cursor = connection.execute( f""" UPDATE article_review_suggestions SET status = {placeholder}, applied_text = {placeholder}, updated_at = {placeholder} WHERE article_id = {placeholder} AND review_kind = {placeholder} AND id = {placeholder} """, ( status.value, applied_text, _datetime_value(updated_at), str(article_id), review_kind.value, str(suggestion_id), ), ) if int(getattr(cursor, "rowcount", 0)) <= 0: raise LookupError(f"Content review suggestion not found: {suggestion_id}") return self.get_suggestion( article_id=article_id, review_kind=review_kind, suggestion_id=suggestion_id, ) class AssetsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, article_id: UUID, section_id: UUID | None, asset_type: AssetType, title: str, prompt: str | None, object_key: str | None, file_url: str | None, alt_text: str | None, caption: str | None, status: AssetStatus, created_at: datetime, updated_at: datetime, ) -> AssetSummary: asset_id = uuid4() placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO assets ( id, article_id, section_id, asset_type, title, prompt, object_key, file_url, alt_text, caption, status, created_at, updated_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) """, ( str(asset_id), str(article_id), _uuid_value(section_id), asset_type.value, title, prompt, object_key, file_url, alt_text, caption, status.value, _datetime_value(created_at), _datetime_value(updated_at), ), ) return self.get(article_id=article_id, asset_id=asset_id) def list_for_article(self, article_id: UUID) -> list[AssetSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, section_id, asset_type, title, prompt, object_key, file_url, alt_text, caption, status, created_at, updated_at FROM assets WHERE article_id = {placeholder} ORDER BY created_at, id """, (str(article_id),), ).fetchall() history_by_asset = self._history_by_asset(article_id) assets: list[AssetSummary] = [] for row in rows: asset = _asset_summary_from_row( row, history=history_by_asset.get(str(_row_value(row, "id")), []), ) assets.append(asset) return assets def get(self, *, article_id: UUID, asset_id: UUID) -> AssetSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, section_id, asset_type, title, prompt, object_key, file_url, alt_text, caption, status, created_at, updated_at FROM assets WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(asset_id)), ).fetchone() if row is None: raise LookupError(f"Asset not found: {asset_id}") history_by_asset = self._history_by_asset(article_id) return _asset_summary_from_row( row, history=history_by_asset.get(str(asset_id), []), ) def update( self, *, article_id: UUID, asset_id: UUID, section_id: UUID | None, title: str, prompt: str | None, object_key: str | None, file_url: str | None, alt_text: str | None, caption: str | None, status: AssetStatus, updated_at: datetime, ) -> AssetSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE assets SET section_id = {placeholder}, title = {placeholder}, prompt = {placeholder}, object_key = {placeholder}, file_url = {placeholder}, alt_text = {placeholder}, caption = {placeholder}, status = {placeholder}, updated_at = {placeholder} WHERE article_id = {placeholder} AND id = {placeholder} """, ( _uuid_value(section_id), title, prompt, object_key, file_url, alt_text, caption, status.value, _datetime_value(updated_at), str(article_id), str(asset_id), ), ) return self.get(article_id=article_id, asset_id=asset_id) def next_revision_index(self, *, article_id: UUID, asset_id: UUID) -> int: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT COALESCE(MAX(revision_index), 0) + 1 AS next_revision FROM asset_revisions WHERE article_id = {placeholder} AND asset_id = {placeholder} """, (str(article_id), str(asset_id)), ).fetchone() return int(_row_value(row, "next_revision")) def create_revision( self, *, article_id: UUID, asset_id: UUID, revision_index: int, action: str, actor_user_id: UUID | None, payload: JsonObject, created_at: datetime, ) -> AssetRevisionSummary: revision_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO asset_revisions ( id, article_id, asset_id, revision_index, action, actor_user_id, payload, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder} ) """, ( str(revision_id), str(article_id), str(asset_id), revision_index, action, _uuid_value(actor_user_id), _json_value(payload), _datetime_value(created_at), ), ) return self.get_revision( article_id=article_id, asset_id=asset_id, revision_id=revision_id, ) def get_revision( self, *, article_id: UUID, asset_id: UUID, revision_id: UUID, ) -> AssetRevisionSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, asset_id, revision_index, action, actor_user_id, payload, created_at FROM asset_revisions WHERE article_id = {placeholder} AND asset_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(asset_id), str(revision_id)), ).fetchone() if row is None: raise LookupError(f"Asset revision not found: {revision_id}") return _asset_revision_from_row(row) def _history_by_asset(self, article_id: UUID) -> dict[str, list[AssetRevisionSummary]]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, asset_id, revision_index, action, actor_user_id, payload, created_at FROM asset_revisions WHERE article_id = {placeholder} ORDER BY revision_index, created_at, id """, (str(article_id),), ).fetchall() history: dict[str, list[AssetRevisionSummary]] = {} for row in rows: asset_id = str(_row_value(row, "asset_id")) history.setdefault(asset_id, []).append(_asset_revision_from_row(row)) return history class ResearchManifestsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, article_id: UUID, agent_job_id: UUID, s3_prefix: str, artifacts: list[JsonObject], created_at: datetime, ) -> ResearchArtifactManifestSummary: manifest_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() source_urls = [artifact["source_url"] for artifact in artifacts] object_keys = [artifact["object_key"] for artifact in artifacts] content_hashes = [artifact["content_hash"] for artifact in artifacts] artifact_types = [artifact["artifact_type"] for artifact in artifacts] metadata_references = [ artifact.get("metadata", {}).get("metadata_object_key") for artifact in artifacts ] with self._repository.connection() as connection: connection.execute( f""" INSERT INTO research_run_manifests ( id, article_id, agent_job_id, s3_prefix, source_urls, object_keys, content_hashes, artifact_types, metadata_references, artifacts, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder} ) """, ( str(manifest_id), str(article_id), str(agent_job_id), s3_prefix, _json_value(source_urls), _json_value(object_keys), _json_value(content_hashes), _json_value(artifact_types), _json_value(metadata_references), _json_value(artifacts), _datetime_value(created_at), ), ) return self.get(manifest_id) def list_for_article(self, article_id: UUID) -> list[ResearchArtifactManifestSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, agent_job_id, s3_prefix, artifacts, created_at FROM research_run_manifests WHERE article_id = {placeholder} ORDER BY created_at """, (str(article_id),), ).fetchall() return [_research_manifest_from_row(row) for row in rows] def get(self, manifest_id: UUID) -> ResearchArtifactManifestSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, agent_job_id, s3_prefix, artifacts, created_at FROM research_run_manifests WHERE id = {placeholder} """, (str(manifest_id),), ).fetchone() if row is None: raise LookupError(f"Research manifest not found: {manifest_id}") return _research_manifest_from_row(row) class EvidenceItemsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, article_id: UUID, source_title: str, source_url: str, source_type: str, source_quality_score: float, summary: str, supports_claims: list[UUID], artifact_manifest_id: UUID, retrieved_at: datetime, review_status: str = "PENDING", ) -> EvidenceSummary: evidence_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO evidence_items ( id, article_id, source_title, source_url, source_type, source_quality_score, summary, supports_claims, artifact_manifest_id, retrieved_at, review_status ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder} ) """, ( str(evidence_id), str(article_id), source_title, source_url, source_type, source_quality_score, summary, _json_value([str(claim_id) for claim_id in supports_claims]), str(artifact_manifest_id), _datetime_value(retrieved_at), review_status, ), ) return self.get(evidence_id) def list_for_article(self, article_id: UUID) -> list[EvidenceSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT {self._select_columns()} FROM evidence_items WHERE article_id = {placeholder} ORDER BY retrieved_at, id """, (str(article_id),), ).fetchall() return [_evidence_from_row(row) for row in rows] def get(self, evidence_id: UUID) -> EvidenceSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT {self._select_columns()} FROM evidence_items WHERE id = {placeholder} """, (str(evidence_id),), ).fetchone() if row is None: raise LookupError(f"Evidence not found: {evidence_id}") return _evidence_from_row(row) def update_review_status( self, *, article_id: UUID, evidence_id: UUID, review_status: str, ) -> EvidenceSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE evidence_items SET review_status = {placeholder} WHERE article_id = {placeholder} AND id = {placeholder} """, (review_status, str(article_id), str(evidence_id)), ) return self.get(evidence_id) def delete( self, *, article_id: UUID, evidence_id: UUID, ) -> None: placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" DELETE FROM evidence_items WHERE article_id = {placeholder} AND id = {placeholder} """, (str(article_id), str(evidence_id)), ) def _select_columns(self) -> str: return """ id, article_id, source_title, source_url, source_type, source_quality_score, summary, supports_claims, artifact_manifest_id, retrieved_at, review_status """ class ClaimsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, article_id: UUID, section_id: UUID | None, claim_text: str, support_status: ClaimSupportStatus, risk_level: ClaimRiskLevel, evidence_item_ids: list[UUID], created_at: datetime, ) -> ClaimSummary: claim_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" INSERT INTO claims ( id, article_id, section_id, claim_text, support_status, risk_level, evidence_item_ids, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder} ) """, ( str(claim_id), str(article_id), _uuid_value(section_id), claim_text, support_status.value, risk_level.value, _json_value([str(evidence_id) for evidence_id in evidence_item_ids]), _datetime_value(created_at), ), ) return self.get(claim_id) def list_for_article(self, article_id: UUID) -> list[ClaimSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, article_id, section_id, claim_text, support_status, risk_level, evidence_item_ids FROM claims WHERE article_id = {placeholder} ORDER BY id """, (str(article_id),), ).fetchall() return [_claim_from_row(row) for row in rows] def get(self, claim_id: UUID) -> ClaimSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, article_id, section_id, claim_text, support_status, risk_level, evidence_item_ids FROM claims WHERE id = {placeholder} """, (str(claim_id),), ).fetchone() if row is None: raise LookupError(f"Claim not found: {claim_id}") return _claim_from_row(row) def update_evidence_links( self, *, claim_id: UUID, evidence_item_ids: list[UUID], support_status: ClaimSupportStatus, ) -> ClaimSummary: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" UPDATE claims SET evidence_item_ids = {placeholder}{json_cast}, support_status = {placeholder} WHERE id = {placeholder} """, ( _json_value([str(evidence_id) for evidence_id in evidence_item_ids]), support_status.value, str(claim_id), ), ) return self.get(claim_id) class AgentJobsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, article_id: UUID | None, parent_job_id: UUID | None, attempt: int, job_type: AgentJobType, agent_profile: str, status: AgentJobStatus, input_files: list[JsonObject], payload: JsonObject | None = None, queued_at: datetime, ) -> AgentJobSummary: job_id = uuid4() placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() sql = f""" INSERT INTO agent_jobs ( id, article_id, parent_job_id, attempt, job_type, agent_profile, status, input_files, payload, queued_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}{json_cast}, {placeholder} ) """ with self._repository.connection() as connection: connection.execute( sql, ( str(job_id), _uuid_value(article_id), _uuid_value(parent_job_id), attempt, job_type.value, agent_profile, status.value, _json_value(input_files), _json_value(payload or {}), _datetime_value(queued_at), ), ) return self.get(job_id) def list(self) -> list[AgentJobSummary]: with self._repository.connection() as connection: rows = connection.execute( f""" SELECT {self._select_columns()} FROM agent_jobs ORDER BY queued_at DESC """ ).fetchall() return [_agent_job_summary_from_row(row) for row in rows] def list_for_article(self, article_id: UUID) -> list[AgentJobSummary]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT {self._select_columns()} FROM agent_jobs WHERE article_id = {placeholder} ORDER BY queued_at DESC """, (str(article_id),), ).fetchall() return [_agent_job_summary_from_row(row) for row in rows] def get(self, job_id: UUID) -> AgentJobSummary: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT {self._select_columns()} FROM agent_jobs WHERE id = {placeholder} """, (str(job_id),), ).fetchone() if row is None: raise LookupError(f"Agent job not found: {job_id}") return _agent_job_summary_from_row(row) def claim_next_queued(self, *, started_at: datetime) -> AgentJobSummary | None: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id FROM agent_jobs WHERE status = {placeholder} ORDER BY queued_at LIMIT 1 """, (AgentJobStatus.QUEUED.value,), ).fetchone() if row is None: return None job_id = _row_value(row, "id") connection.execute( f""" UPDATE agent_jobs SET status = {placeholder}, started_at = {placeholder} WHERE id = {placeholder} AND status = {placeholder} """, ( AgentJobStatus.RUNNING.value, _datetime_value(started_at), str(job_id), AgentJobStatus.QUEUED.value, ), ) return self.get(UUID(str(job_id))) def complete( self, *, job_id: UUID, status: AgentJobStatus, workspace_path: str | None, output_files: list[JsonObject], payload: JsonObject | None, error_category: AgentJobErrorCategory | None, error_message: str | None, stdout: str, stderr: str, exit_code: int | None, duration_ms: int | None, finished_at: datetime, ) -> AgentJobSummary: existing = self.get(job_id) if existing.status == AgentJobStatus.CANCELLED: return existing placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() with self._repository.connection() as connection: connection.execute( f""" UPDATE agent_jobs SET status = {placeholder}, workspace_path = {placeholder}, output_files = {placeholder}{json_cast}, payload = {placeholder}{json_cast}, error_category = {placeholder}, error_message = {placeholder}, stdout = {placeholder}, stderr = {placeholder}, exit_code = {placeholder}, duration_ms = {placeholder}, finished_at = {placeholder} WHERE id = {placeholder} """, ( status.value, workspace_path, _json_value(output_files), _json_value(payload or {}), _agent_error_category_value(error_category), error_message, stdout, stderr, exit_code, duration_ms, _datetime_value(finished_at), str(job_id), ), ) return self.get(job_id) def cancel(self, *, job_id: UUID, finished_at: datetime) -> AgentJobSummary: existing = self.get(job_id) if existing.status in {AgentJobStatus.SUCCEEDED, AgentJobStatus.FAILED}: return existing placeholder = self._repository.placeholder() with self._repository.connection() as connection: connection.execute( f""" UPDATE agent_jobs SET status = {placeholder}, error_message = {placeholder}, finished_at = {placeholder} WHERE id = {placeholder} """, ( AgentJobStatus.CANCELLED.value, "Cancelled", _datetime_value(finished_at), str(job_id), ), ) return self.get(job_id) def _select_columns(self) -> str: return """ id, article_id, parent_job_id, attempt, job_type, agent_profile, status, workspace_path, input_files, output_files, payload, error_category, error_message, stdout, stderr, exit_code, duration_ms, queued_at, started_at, finished_at """ class ScriptConfigVersionsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def upsert( self, *, version_id: UUID, target_site_id: UUID, version: int, status: ScriptConfigVersionStatus, created_by: UUID, created_at: datetime, updated_at: datetime, diff: JsonObject, rollback_target_version_id: UUID | None, activated_at: datetime | None, publishing_yaml: str, publishing_yaml_hash: str, transform_script: str, transform_script_hash: str, ) -> dict[str, Any]: placeholder = self._repository.placeholder() json_cast = self._repository.json_cast() sql = f""" INSERT INTO script_config_versions ( id, target_site_id, version, status, created_by, created_at, updated_at, diff, rollback_target_version_id, activated_at, publishing_yaml, publishing_yaml_hash, transform_script, transform_script_hash ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}{json_cast}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) ON CONFLICT (target_site_id, version) DO UPDATE SET status = excluded.status, created_by = excluded.created_by, updated_at = excluded.updated_at, diff = excluded.diff, rollback_target_version_id = excluded.rollback_target_version_id, activated_at = excluded.activated_at, publishing_yaml = excluded.publishing_yaml, publishing_yaml_hash = excluded.publishing_yaml_hash, transform_script = excluded.transform_script, transform_script_hash = excluded.transform_script_hash """ params = ( str(version_id), str(target_site_id), version, status.value, str(created_by), _datetime_value(created_at), _datetime_value(updated_at), _json_value(diff), _uuid_value(rollback_target_version_id), _datetime_value(activated_at), publishing_yaml, publishing_yaml_hash, transform_script, transform_script_hash, ) with self._repository.connection() as connection: connection.execute(sql, params) return self.get_by_site_and_version(target_site_id=target_site_id, version=version) def get_by_site_and_version( self, *, target_site_id: UUID, version: int ) -> dict[str, Any]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT * FROM script_config_versions WHERE target_site_id = {placeholder} AND version = {placeholder} """, (str(target_site_id), version), ).fetchone() if row is None: raise LookupError( f"Script config version not found: {target_site_id} v{version}" ) return _plain_row(row) def get_by_id(self, version_id: UUID) -> dict[str, Any]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT * FROM script_config_versions WHERE id = {placeholder} """, (str(version_id),), ).fetchone() if row is None: raise LookupError(f"Script config version not found: {version_id}") return _plain_row(row) def activate( self, *, target_site_id: UUID, version_id: UUID, activated_at: datetime, rollback_target_version_id: UUID | None = None, ) -> dict[str, Any]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: existing = connection.execute( f""" SELECT id FROM script_config_versions WHERE id = {placeholder} AND target_site_id = {placeholder} """, (str(version_id), str(target_site_id)), ).fetchone() if existing is None: raise LookupError( f"Script config version not found: {target_site_id} {version_id}" ) connection.execute( f""" UPDATE script_config_versions SET status = {placeholder}, updated_at = {placeholder} WHERE target_site_id = {placeholder} AND id <> {placeholder} """, ( ScriptConfigVersionStatus.DEPRECATED.value, _datetime_value(activated_at), str(target_site_id), str(version_id), ), ) connection.execute( f""" UPDATE script_config_versions SET status = {placeholder}, activated_at = {placeholder}, updated_at = {placeholder}, rollback_target_version_id = COALESCE({placeholder}, rollback_target_version_id) WHERE id = {placeholder} """, ( ScriptConfigVersionStatus.ACTIVE.value, _datetime_value(activated_at), _datetime_value(activated_at), _uuid_value(rollback_target_version_id), str(version_id), ), ) connection.execute( f""" UPDATE target_sites SET active_script_config_version_id = {placeholder}, updated_at = {placeholder} WHERE id = {placeholder} """, (str(version_id), _datetime_value(activated_at), str(target_site_id)), ) return self.get_by_id(version_id) def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT * FROM script_config_versions WHERE target_site_id = {placeholder} ORDER BY version """, (str(target_site_id),), ).fetchall() return [_plain_row(row) for row in rows] class ScriptConfigVersionAuditEventsRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def create( self, *, target_site_id: UUID, version_id: UUID, event_type: str, actor_user_id: UUID | None, payload: JsonObject, created_at: datetime, ) -> dict[str, Any]: placeholder = self._repository.placeholder() sql = f""" INSERT INTO script_config_version_events ( id, target_site_id, version_id, event_type, actor_user_id, payload, created_at ) VALUES ( {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder} ) """ params = ( str(uuid4()), str(target_site_id), str(version_id), event_type, _uuid_value(actor_user_id), _json_value(payload), _datetime_value(created_at), ) with self._repository.connection() as connection: connection.execute(sql, params) return self.get(event_type=event_type, target_site_id=target_site_id, version_id=version_id) def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: rows = connection.execute( f""" SELECT id, target_site_id, version_id, event_type, actor_user_id, payload, created_at FROM script_config_version_events WHERE target_site_id = {placeholder} ORDER BY created_at """, (str(target_site_id),), ).fetchall() return [_script_config_version_event_from_row(row) for row in rows] def get( self, *, target_site_id: UUID, version_id: UUID, event_type: str, ) -> dict[str, Any]: placeholder = self._repository.placeholder() with self._repository.connection() as connection: row = connection.execute( f""" SELECT id, target_site_id, version_id, event_type, actor_user_id, payload, created_at FROM script_config_version_events WHERE target_site_id = {placeholder} AND version_id = {placeholder} AND event_type = {placeholder} """, (str(target_site_id), str(version_id), event_type), ).fetchone() if row is None: raise LookupError( f"Script config version event not found: {target_site_id} {version_id} {event_type}" ) return _script_config_version_event_from_row(row) class SchemaRepository: def __init__(self, repository: BackendRepository) -> None: self._repository = repository def list_tables(self) -> set[str]: with self._repository.connection() as connection: if self._repository.dialect == "sqlite": rows = connection.execute( """ SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' """ ).fetchall() else: rows = connection.execute( """ SELECT table_name AS name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' """ ).fetchall() return {_row_value(row, "name") for row in rows} def list_columns(self, table_name: str) -> set[str]: with self._repository.connection() as connection: if self._repository.dialect == "sqlite": rows = connection.execute(f"PRAGMA table_info({table_name})").fetchall() return {_row_value(row, "name") for row in rows} rows = connection.execute( """ SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = %s """, (table_name,), ).fetchall() return {_row_value(row, "column_name") for row in rows} def list_indexes(self, table_name: str) -> set[str]: with self._repository.connection() as connection: if self._repository.dialect == "sqlite": rows = connection.execute(f"PRAGMA index_list({table_name})").fetchall() return {_row_value(row, "name") for row in rows} rows = connection.execute( """ SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = %s """, (table_name,), ).fetchall() return {_row_value(row, "indexname") for row in rows} def count_rows(self, table_name: str) -> int: with self._repository.connection() as connection: row = connection.execute(f"SELECT COUNT(*) AS count FROM {table_name}").fetchone() return int(_row_value(row, "count")) def _user_from_row(row: Any) -> UserSummary: return UserSummary( id=_row_value(row, "id"), display_name=_row_value(row, "display_name"), role=_row_value(row, "role"), ) def _target_site_from_row(row: Any) -> TargetSiteConfig: return TargetSiteConfig( id=_row_value(row, "id"), name=_row_value(row, "name"), slug=_row_value(row, "slug"), publishing_type=_row_value(row, "publishing_type"), default_language=_row_value(row, "default_language"), brand_voice=_row_value(row, "brand_voice"), audience=_row_value(row, "audience"), seo_rules=_json_from_row(row, "seo_rules"), visual_rules=_json_from_row(row, "visual_rules"), source_rules=_json_from_row(row, "source_rules"), publishing_rules=_json_from_row(row, "publishing_rules"), active_script_config_version_id=_row_value( row, "active_script_config_version_id" ), created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _article_summary_from_row(row: Any) -> ArticleSummary: return ArticleSummary( id=_row_value(row, "id"), target_site_id=_row_value(row, "target_site_id"), status=_row_value(row, "status"), publishing_status=_row_value(row, "publishing_status"), brief_description=_row_value(row, "brief_description"), working_title=_row_value(row, "working_title"), language=_row_value(row, "language"), content_type=_row_value(row, "content_type"), primary_keyword=_row_value(row, "primary_keyword"), assigned_editor_id=_row_value(row, "assigned_editor_id"), created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _workflow_event_from_row(row: Any) -> WorkflowEventSummary: return WorkflowEventSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), event_type=_row_value(row, "event_type"), from_status=_row_value(row, "from_status"), to_status=_row_value(row, "to_status"), actor_user_id=_row_value(row, "actor_user_id"), payload=_json_from_row(row, "payload"), created_at=_row_value(row, "created_at"), ) def _boundary_question_from_row(row: Any) -> BoundaryQuestionSummary: return BoundaryQuestionSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), sort_order=_row_value(row, "sort_order"), category=_row_value(row, "category"), question=_row_value(row, "question"), answer=_row_value(row, "answer"), is_required=bool(_row_value(row, "is_required")), created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _plan_section_from_row(row: Any) -> PlanSectionSummary: return PlanSectionSummary( id=_row_value(row, "id"), article_plan_id=_row_value(row, "article_plan_id"), sort_order=_row_value(row, "sort_order"), heading=_row_value(row, "heading"), purpose=_row_value(row, "purpose"), key_points=_json_from_row(row, "key_points"), evidence_needs=_json_from_row(row, "evidence_needs"), claims_to_support=_json_from_row(row, "claims_to_support"), target_word_count=_row_value(row, "target_word_count"), ) def _plan_summary_from_row( row: Any, sections: list[PlanSectionSummary], ) -> PlanSummary: return PlanSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), version=_row_value(row, "version"), status=_row_value(row, "status"), title_options=_json_from_row(row, "title_options"), recommended_title=_row_value(row, "recommended_title"), reader_persona=_row_value(row, "reader_persona"), search_intent=_row_value(row, "search_intent"), thesis=_row_value(row, "thesis"), sections=sections, claims_to_prove=_json_from_row(row, "claims_to_prove"), evidence_needs=_json_from_row(row, "evidence_needs"), visual_needs=_json_from_row(row, "visual_needs"), seo_notes=_json_from_row(row, "seo_notes"), source_requirements=_json_from_row(row, "source_requirements"), excluded_sources=_json_from_row(row, "excluded_sources"), tone=_row_value(row, "tone"), audience=_row_value(row, "audience"), risks=_json_from_row(row, "risks"), created_at=_row_value(row, "created_at"), ) def _research_manifest_from_row(row: Any) -> ResearchArtifactManifestSummary: return ResearchArtifactManifestSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), agent_job_id=_row_value(row, "agent_job_id"), s3_prefix=_row_value(row, "s3_prefix"), artifacts=[ ResearchArtifactSummary(**artifact) for artifact in _json_from_row(row, "artifacts") ], created_at=_row_value(row, "created_at"), ) def _evidence_from_row(row: Any) -> EvidenceSummary: return EvidenceSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), source_title=_row_value(row, "source_title"), source_url=_row_value(row, "source_url"), source_type=_row_value(row, "source_type"), source_quality_score=float(_row_value(row, "source_quality_score")), summary=_row_value(row, "summary"), supports_claims=[UUID(value) for value in _json_from_row(row, "supports_claims")], artifact_manifest_id=_row_value(row, "artifact_manifest_id"), retrieved_at=_row_value(row, "retrieved_at"), review_status=_row_value(row, "review_status"), ) def _claim_from_row(row: Any) -> ClaimSummary: return ClaimSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), section_id=_row_value(row, "section_id"), claim_text=_row_value(row, "claim_text"), support_status=_row_value(row, "support_status"), risk_level=_row_value(row, "risk_level"), evidence_item_ids=[ UUID(value) for value in _json_from_row(row, "evidence_item_ids") ], ) def _draft_summary_from_row(row: Any) -> DraftSummary: return DraftSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), version=_row_value(row, "version"), title=_row_value(row, "title"), slug=_row_value(row, "slug"), meta_title=_row_value(row, "meta_title"), meta_description=_row_value(row, "meta_description"), body_object_key=_row_value(row, "body_object_key"), body_markdown=_row_value(row, "body_markdown"), faq_items=[ DraftFaqItem.model_validate(item) for item in _json_from_row(row, "faq_items") ], visual_placeholders=_json_from_row(row, "visual_placeholders"), evidence_references=[ UUID(value) for value in _json_from_row(row, "evidence_references") ], unsupported_claim_warnings=_json_from_row( row, "unsupported_claim_warnings" ), based_on_draft_id=_row_value(row, "based_on_draft_id"), status=_row_value(row, "status"), created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _content_review_report_from_row( row: Any, *, issues: list[ContentReviewIssueSummary], ) -> ContentReviewReportSummary: return ContentReviewReportSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), review_kind=_row_value(row, "review_kind"), draft_id=_row_value(row, "draft_id"), score=_row_value(row, "score"), issues=issues, recommended_slug=_row_value(row, "recommended_slug"), recommended_title=_row_value(row, "recommended_title"), schema_json=_json_from_row(row, "schema_json"), rules_snapshot=_json_from_row(row, "rules_snapshot"), created_at=_row_value(row, "created_at"), ) def _content_review_suggestion_from_row(row: Any) -> ContentReviewSuggestionSummary: return ContentReviewSuggestionSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), review_kind=_row_value(row, "review_kind"), report_id=_row_value(row, "report_id"), suggestion_key=_row_value(row, "suggestion_key"), severity=_row_value(row, "severity"), location=_row_value(row, "location"), message=_row_value(row, "message"), suggested_fix=_row_value(row, "suggested_fix"), suggested_rewrite=_row_value(row, "suggested_rewrite"), patch=_json_from_row(row, "patch"), status=_row_value(row, "status"), applied_text=_row_value(row, "applied_text"), created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _asset_revision_from_row(row: Any) -> AssetRevisionSummary: return AssetRevisionSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), asset_id=_row_value(row, "asset_id"), revision_index=_row_value(row, "revision_index"), action=_row_value(row, "action"), actor_user_id=_row_value(row, "actor_user_id"), payload=_json_from_row(row, "payload"), created_at=_row_value(row, "created_at"), ) def _asset_summary_from_row( row: Any, *, history: list[AssetRevisionSummary] | None = None, ) -> AssetSummary: return AssetSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), section_id=_row_value(row, "section_id"), asset_type=_row_value(row, "asset_type"), title=_row_value(row, "title"), prompt=_row_value(row, "prompt"), object_key=_row_value(row, "object_key"), file_url=_row_value(row, "file_url"), alt_text=_row_value(row, "alt_text"), caption=_row_value(row, "caption"), status=_row_value(row, "status"), history=history or [], created_at=_row_value(row, "created_at"), updated_at=_row_value(row, "updated_at"), ) def _agent_job_summary_from_row(row: Any) -> AgentJobSummary: return AgentJobSummary( id=_row_value(row, "id"), article_id=_row_value(row, "article_id"), parent_job_id=_row_value(row, "parent_job_id"), attempt=_row_value(row, "attempt"), job_type=_row_value(row, "job_type"), agent_profile=_row_value(row, "agent_profile"), status=_row_value(row, "status"), workspace_path=_row_value(row, "workspace_path"), input_files=_json_from_row(row, "input_files"), output_files=_json_from_row(row, "output_files"), payload=_json_from_row(row, "payload"), error_category=_row_value(row, "error_category"), error_message=_row_value(row, "error_message"), stdout=_row_value(row, "stdout"), stderr=_row_value(row, "stderr"), exit_code=_row_value(row, "exit_code"), duration_ms=_row_value(row, "duration_ms"), queued_at=_row_value(row, "queued_at"), started_at=_row_value(row, "started_at"), finished_at=_row_value(row, "finished_at"), ) def _script_config_version_event_from_row(row: Any) -> dict[str, Any]: return { "id": _row_value(row, "id"), "target_site_id": _row_value(row, "target_site_id"), "version_id": _row_value(row, "version_id"), "event_type": _row_value(row, "event_type"), "actor_user_id": _row_value(row, "actor_user_id"), "payload": _json_from_row(row, "payload"), "created_at": _row_value(row, "created_at"), } def _plain_row(row: Any) -> dict[str, Any]: if isinstance(row, sqlite3.Row): result = dict(row) else: result = dict(row) for key in ( "diff", "seo_rules", "visual_rules", "source_rules", "publishing_rules", ): if key in result: result[key] = _json_decode(result[key]) return result def _row_value(row: Any, key: str) -> Any: return row[key] def _json_from_row(row: Any, key: str) -> JsonObject: return _json_decode(_row_value(row, key)) def _json_decode(value: Any) -> Any: if isinstance(value, str): return json.loads(value) return value def _json_value(value: Any) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":")) def _uuid_value(value: UUID | None) -> str | None: if value is None: return None return str(value) def _datetime_value(value: datetime | None) -> str | None: if value is None: return None return value.isoformat() def _bool_value(value: bool) -> bool: return bool(value) def _article_status_value(value: ArticleWorkflowStatus | None) -> str | None: if value is None: return None return value.value def _agent_error_category_value(value: AgentJobErrorCategory | None) -> str | None: if value is None: return None return value.value