From c130461a045b56d11672e116d1a28df3ef04f74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 7 Aug 2026 12:57:37 +0200 Subject: [PATCH 01/14] Added backend for storing runs to the database --- .../assignment_loader_component.py | 4 +- .../persistence/assignment_saver_component.py | 14 +- .../ai/persistence/database.py | 224 +++++++++++++++++- .../ai/persistence/saver_component.py | 4 +- .../ai/persistence/schema.py | 39 +++ .../ai/run_pipeline.py | 10 +- .../ai_document_plugin_service/api/routes.py | 46 ++-- .../ai_document_plugin_service/api/types.py | 23 +- .../20260807_01_create_generation_table.py | 80 +++++++ .../service/pipeline_queue_manager.py | 14 +- .../service/pipeline_service.py | 192 +++++++++------ 11 files changed, 514 insertions(+), 136 deletions(-) create mode 100644 service/src/ai_document_plugin_service/migrations/versions/20260807_01_create_generation_table.py diff --git a/service/src/ai_document_plugin_service/ai/persistence/assignment_loader_component.py b/service/src/ai_document_plugin_service/ai/persistence/assignment_loader_component.py index 70143cc..fcfa622 100644 --- a/service/src/ai_document_plugin_service/ai/persistence/assignment_loader_component.py +++ b/service/src/ai_document_plugin_service/ai/persistence/assignment_loader_component.py @@ -15,7 +15,7 @@ def __init__(self, database: Database) -> None: assignments=JsonValue | None, found=bool, ) - async def run_async(self, knowledge_model_uuid: str, template_uuid: UUID) -> dict[str, Any]: + async def run_async(self, knowledge_model_uuid: UUID, template_uuid: UUID) -> dict[str, Any]: assignments = await self.database.get_assignments(knowledge_model_uuid, template_uuid) return { @@ -27,7 +27,7 @@ async def run_async(self, knowledge_model_uuid: str, template_uuid: UUID) -> dic assignments=JsonValue | None, found=bool, ) - def run(self, knowledge_model_uuid: str, template_uuid: UUID) -> dict[str, Any]: + def run(self, knowledge_model_uuid: UUID, template_uuid: UUID) -> dict[str, Any]: """Async-only component; the sync pipeline entrypoint is intentionally unsupported.""" msg = f'{type(self).__name__} is async-only; use run_async() / AsyncPipeline.run_async()' raise NotImplementedError( diff --git a/service/src/ai_document_plugin_service/ai/persistence/assignment_saver_component.py b/service/src/ai_document_plugin_service/ai/persistence/assignment_saver_component.py index 530449f..675d417 100644 --- a/service/src/ai_document_plugin_service/ai/persistence/assignment_saver_component.py +++ b/service/src/ai_document_plugin_service/ai/persistence/assignment_saver_component.py @@ -44,7 +44,7 @@ def __init__(self, saver: Saver) -> None: @component.output_types(assignments=list[SerializedSectionAssignment], stats=AssignmentStats) async def run_async( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, template_uuid: UUID, @@ -80,7 +80,7 @@ async def run_async( @component.output_types(assignments=list[SerializedSectionAssignment], stats=AssignmentStats) def run( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, template_uuid: UUID, @@ -101,7 +101,7 @@ class Saver(ABC): @abstractmethod async def save( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, assignments: JsonValue, @@ -118,7 +118,7 @@ async def save( class FileSaver(Saver): async def save( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, assignments: JsonValue, @@ -154,12 +154,12 @@ async def save( @staticmethod def _build_filename( - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, created_at: datetime | None = None, ) -> str: - normalized_uuid = _normalize_filename_part(knowledge_model_uuid) + normalized_uuid = _normalize_filename_part(str(knowledge_model_uuid)) normalized_name = _normalize_filename_part(knowledge_model_name) normalized_version = _normalize_filename_part(knowledge_model_version) @@ -176,7 +176,7 @@ def __init__(self, database: Database) -> None: async def save( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, assignments: JsonValue, diff --git a/service/src/ai_document_plugin_service/ai/persistence/database.py b/service/src/ai_document_plugin_service/ai/persistence/database.py index e65d132..1782de6 100644 --- a/service/src/ai_document_plugin_service/ai/persistence/database.py +++ b/service/src/ai_document_plugin_service/ai/persistence/database.py @@ -5,7 +5,7 @@ from contextvars import ContextVar from dataclasses import dataclass from datetime import UTC, datetime -from typing import Any +from typing import Any, TypedDict, Unpack from uuid import UUID, uuid4 from sqlalchemy import ColumnElement, Connection, Row, and_, inspect, or_ @@ -54,6 +54,54 @@ def from_row(cls, row: Row) -> 'TemplateRecord': ) +class GenerationUpdate(TypedDict, total=False): + status: str + knowledge_model_uuid: UUID | None + error_type: str | None + error_message: str | None + result_markdown: str | None + progress_message: str | None + + +@dataclass(frozen=True) +class GenerationRecord: + """A raw generation (pipeline run) row.""" + + run_id: UUID + questionnaire_uuid: UUID + template_uuid: UUID + title: str + knowledge_model_uuid: UUID | None + user_uuid: UUID + tenant_uuid: UUID + status: str + error_type: str | None + error_message: str | None + result_markdown: str | None + progress_message: str | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_row(cls, row: Row) -> 'GenerationRecord': + return cls( + run_id=row.run_id, + questionnaire_uuid=row.questionnaire_uuid, + template_uuid=row.template_uuid, + title=row.title, + knowledge_model_uuid=row.knowledge_model_uuid, + user_uuid=row.user_uuid, + tenant_uuid=row.tenant_uuid, + status=row.status, + error_type=row.error_type, + error_message=row.error_message, + result_markdown=row.result_markdown, + progress_message=row.progress_message, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + class Database(ABC): @abstractmethod def transaction(self) -> AbstractAsyncContextManager[None]: @@ -103,7 +151,7 @@ async def delete_template( @abstractmethod async def save_assignments( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, assignments: JsonValue, @@ -126,7 +174,7 @@ async def save_template( @abstractmethod async def get_assignments( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, template_uuid: UUID, ) -> JsonValue | None: """Get assignments from a database backend.""" @@ -153,7 +201,7 @@ async def get_template( async def save_result( self, template_uuid: UUID, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, user_uuid: UUID, tenant_uuid: UUID, prepolished_markdown: str, @@ -183,6 +231,50 @@ async def update_result( ) -> None: """Persist a markdown result in a database backend.""" + @abstractmethod + async def create_generation( + self, + questionnaire_uuid: UUID, + template_uuid: UUID, + title: str, + user_uuid: UUID, + tenant_uuid: UUID, + status: str, + ) -> UUID: + """Create a new generation (pipeline run) row. Return the created run id.""" + + @abstractmethod + async def update_generation( + self, + run_id: UUID, + tenant_uuid: UUID, + **updates: Unpack[GenerationUpdate], + ) -> GenerationRecord | None: + """Partially update a generation row and return the updated record. + + Only the fields passed in ``updates`` are changed; any nullable field may be + set to ``None`` to clear it. Returns ``None`` if no row matches + ``run_id``/``tenant_uuid``. + """ + + @abstractmethod + async def get_generation( + self, + run_id: UUID, + tenant_uuid: UUID, + user_uuid: UUID, + ) -> GenerationRecord | None: + """Get a single generation row, scoped to its owning tenant and user.""" + + @abstractmethod + async def list_generations( + self, + questionnaire_uuid: UUID, + tenant_uuid: UUID, + user_uuid: UUID, + ) -> list[GenerationRecord]: + """List a user's generations for a project, newest first.""" + class PostgresDB(Database): def __init__( @@ -204,6 +296,7 @@ def __init__( self.assignment_table = schema.assignment_table self.template_table = schema.template_table self.result_table = schema.result_table + self.generation_table = schema.generation_table self._database_verified = False async def dispose(self) -> None: @@ -252,7 +345,7 @@ async def _ensure_schema(self) -> None: async with self.engine.connect() as connection: existing_tables = await connection.run_sync(self._list_existing_tables) - required_tables = {'alembic_version', 'template', 'assignment', 'result'} + required_tables = {'alembic_version', 'template', 'assignment', 'result', 'generation'} missing_tables = sorted(required_tables - existing_tables) if missing_tables: @@ -267,7 +360,7 @@ async def _ensure_schema(self) -> None: async def save_assignments( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, knowledge_model_name: str, knowledge_model_version: str, assignments: JsonValue, @@ -419,7 +512,7 @@ async def save_template( async def get_assignments( self, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, template_uuid: UUID, ) -> JsonValue | None: await self._ensure_schema() @@ -509,7 +602,7 @@ async def get_template( async def save_result( self, template_uuid: UUID, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, user_uuid: UUID, tenant_uuid: UUID, prepolished_markdown: str, @@ -620,6 +713,121 @@ async def update_result( self.schema_name, ) + async def create_generation( + self, + questionnaire_uuid: UUID, + template_uuid: UUID, + title: str, + user_uuid: UUID, + tenant_uuid: UUID, + status: str, + ) -> UUID: + run_id = uuid4() + await self._ensure_schema() + statement = self.generation_table.insert().values( + run_id=run_id, + questionnaire_uuid=questionnaire_uuid, + template_uuid=template_uuid, + title=title, + user_uuid=user_uuid, + tenant_uuid=tenant_uuid, + status=status, + ) + + async with self._connect() as connection: + await connection.execute(statement) + + logger.debug( + 'Created generation run_id=%s in %s.generation', + run_id, + self.schema_name, + ) + return run_id + + async def update_generation( + self, + run_id: UUID, + tenant_uuid: UUID, + **updates: Unpack[GenerationUpdate], + ) -> GenerationRecord | None: + await self._ensure_schema() + + unknown_fields = set(updates) - GenerationUpdate.__optional_keys__ + if unknown_fields: + msg = f'Cannot update unknown generation fields: {sorted(unknown_fields)}' + raise ValueError(msg) + + now = datetime.now(tz=UTC) + statement = ( + self.generation_table.update() + .where( + (self.generation_table.c.run_id == run_id) + & (self.generation_table.c.tenant_uuid == tenant_uuid) + ) + .values(**updates, updated_at=now) + .returning(*self.generation_table.c) + ) + + async with self._connect() as connection: + result = await connection.execute(statement) + row = result.fetchone() + + if row is None: + return None + + logger.debug( + 'Updated generation run_id=%s in %s.generation', + run_id, + self.schema_name, + ) + return GenerationRecord.from_row(row) + + async def get_generation( + self, + run_id: UUID, + tenant_uuid: UUID, + user_uuid: UUID, + ) -> GenerationRecord | None: + await self._ensure_schema() + statement = self.generation_table.select().where( + and_( + self.generation_table.c.run_id == run_id, + self.generation_table.c.tenant_uuid == tenant_uuid, + self.generation_table.c.user_uuid == user_uuid, + ), + ) + + async with self._connect() as connection: + result = await connection.execute(statement) + row = result.fetchone() + + return GenerationRecord.from_row(row) if row is not None else None + + async def list_generations( + self, + questionnaire_uuid: UUID, + tenant_uuid: UUID, + user_uuid: UUID, + ) -> list[GenerationRecord]: + await self._ensure_schema() + statement = ( + self.generation_table.select() + .where( + and_( + self.generation_table.c.questionnaire_uuid == questionnaire_uuid, + self.generation_table.c.tenant_uuid == tenant_uuid, + self.generation_table.c.user_uuid == user_uuid, + ), + ) + .order_by(self.generation_table.c.created_at.desc()) + ) + + async with self._connect() as connection: + result = await connection.execute(statement) + rows = result.fetchall() + + return [GenerationRecord.from_row(row) for row in rows] + def _validate_identifier(value: str) -> str: if not value.replace('_', '').isalnum() or not (value[0].isalpha() or value[0] == '_'): diff --git a/service/src/ai_document_plugin_service/ai/persistence/saver_component.py b/service/src/ai_document_plugin_service/ai/persistence/saver_component.py index b19bae0..872b6c7 100644 --- a/service/src/ai_document_plugin_service/ai/persistence/saver_component.py +++ b/service/src/ai_document_plugin_service/ai/persistence/saver_component.py @@ -20,7 +20,7 @@ def __init__(self, database: Database) -> None: async def run_async( self, template_uuid: UUID, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, user_uuid: UUID, tenant_uuid: UUID, debug_markdown: str, @@ -44,7 +44,7 @@ async def run_async( def run( self, template_uuid: UUID, - knowledge_model_uuid: str, + knowledge_model_uuid: UUID, user_uuid: UUID, tenant_uuid: UUID, debug_markdown: str, diff --git a/service/src/ai_document_plugin_service/ai/persistence/schema.py b/service/src/ai_document_plugin_service/ai/persistence/schema.py index ee1a47d..b358fa0 100644 --- a/service/src/ai_document_plugin_service/ai/persistence/schema.py +++ b/service/src/ai_document_plugin_service/ai/persistence/schema.py @@ -22,6 +22,7 @@ class PersistenceSchema: assignment_table: Table template_table: Table result_table: Table + generation_table: Table def create_persistence_schema(schema_name: str) -> PersistenceSchema: @@ -116,9 +117,47 @@ def create_persistence_schema(schema_name: str) -> PersistenceSchema: ), ) + generation_table = Table( + 'generation', + metadata, + Column('run_id', UUID(as_uuid=True), primary_key=True), + Column('questionnaire_uuid', UUID(as_uuid=True), nullable=False), + Column('template_uuid', UUID(as_uuid=True), ForeignKey('template.uuid'), nullable=False), + Column('title', Text, nullable=False), + # Only known once the run has fetched the questionnaire from DSW. + Column('knowledge_model_uuid', UUID(as_uuid=True), nullable=True), + Column('user_uuid', UUID(as_uuid=True), nullable=False), + Column('tenant_uuid', UUID(as_uuid=True), nullable=False), + Column('status', Text, nullable=False), + Column('error_type', Text, nullable=True), + Column('error_message', Text, nullable=True), + Column('result_markdown', Text, nullable=True), + Column('progress_message', Text, nullable=True), + Column( + 'created_at', + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ), + Column( + 'updated_at', + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ), + Index( + 'ix_generation_questionnaire_user_tenant_created_at', + 'questionnaire_uuid', + 'user_uuid', + 'tenant_uuid', + 'created_at', + ), + ) + return PersistenceSchema( metadata=metadata, assignment_table=assignment_table, template_table=template_table, result_table=result_table, + generation_table=generation_table, ) diff --git a/service/src/ai_document_plugin_service/ai/run_pipeline.py b/service/src/ai_document_plugin_service/ai/run_pipeline.py index ee128ee..0df8efe 100644 --- a/service/src/ai_document_plugin_service/ai/run_pipeline.py +++ b/service/src/ai_document_plugin_service/ai/run_pipeline.py @@ -3,8 +3,9 @@ import argparse import logging import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import TYPE_CHECKING +from uuid import UUID from haystack import AsyncPipeline from haystack.components.routers import ConditionalRouter @@ -30,9 +31,6 @@ from ai_document_plugin_service.ai.polishing.llm import SectionPolishingLLM if TYPE_CHECKING: - from collections.abc import Mapping - from uuid import UUID - from haystack.components.routers.conditional_router import Route from ai_document_plugin_service.ai.common.llm_client import LLMClient @@ -122,13 +120,13 @@ async def run_pipeline( dsw_client: DSWClient, model_name: str, on_progress: ProgressCallback | None = None, -) -> tuple[str, str]: +) -> tuple[UUID, str]: t1 = time.time() km_data = await dsw_client.get_questionnaire_detail(questionnaire_uuid=questionnaire_uuid) replies = km_data['replies'] km = km_data['knowledgeModel'] - knowledge_model_uuid = km_data['knowledgeModelPackage']['uuid'] + knowledge_model_uuid = UUID(km_data['knowledgeModelPackage']['uuid']) knowledge_model_name = km_data['knowledgeModelPackage']['name'] knowledge_model_version = km_data['knowledgeModelPackage']['version'] diff --git a/service/src/ai_document_plugin_service/api/routes.py b/service/src/ai_document_plugin_service/api/routes.py index be77858..7e245e8 100644 --- a/service/src/ai_document_plugin_service/api/routes.py +++ b/service/src/ai_document_plugin_service/api/routes.py @@ -1,19 +1,18 @@ -from uuid import UUID, uuid4 +from typing import Annotated +from uuid import UUID import fastapi from ai_document_plugin_service.api.auth import verify_authenticated from ai_document_plugin_service.api.types import ( PipelineRunRequest, - PipelineRunResponse, PipelineSaveRequest, - PipelineStatus, PipelineStatusResponse, + PipelineSummaryResponse, TemplateCreateRequest, TemplateDetail, TemplateListItem, TemplateUpdateRequest, - _model_from_fields, ) from ai_document_plugin_service.di import AuthenticatedDI, ConfigDI, PipelineServiceDI, TemplateServiceDI from ai_document_plugin_service.service.errors import NotFoundError @@ -66,32 +65,37 @@ async def start_pipeline( config: ConfigDI, templates: TemplateServiceDI, pipeline: PipelineServiceDI, -) -> PipelineRunResponse: +) -> PipelineStatusResponse: template = await templates.get(auth, payload.template_uuid) - run_id = str(uuid4()) - pipeline.enqueue_pipeline_job( - run_id, + run_id = await pipeline.enqueue_pipeline_job( payload, template.title, auth, config, ) - return _model_from_fields( - PipelineRunResponse, - status=PipelineStatus.ACCEPTED, - run_id=run_id, - questionnaire_uuid=payload.questionnaire_uuid, - user_uuid=auth.user_uuid, - tenant_uuid=auth.tenant_uuid, - template_uuid=payload.template_uuid, - template_title=template.title, - ) + status = await pipeline.get_pipeline_status(run_id, auth) + if status is None: + raise NotFoundError(NotFoundError.PIPELINE_RUN_MESSAGE) + return status + + +@protected_router.get('/pipelines') +async def list_pipeline_history( + pipeline: PipelineServiceDI, + auth: AuthenticatedDI, + questionnaire_uuid: Annotated[UUID, fastapi.Query(alias='questionnaireUuid')], +) -> list[PipelineSummaryResponse]: + return await pipeline.list_history(questionnaire_uuid, auth) @protected_router.get('/pipelines/status/{run_id}') -def get_pipeline_status(run_id: str, pipeline: PipelineServiceDI) -> PipelineStatusResponse: - status = pipeline.get_pipeline_status(run_id) +async def get_pipeline_status( + run_id: UUID, + pipeline: PipelineServiceDI, + auth: AuthenticatedDI, +) -> PipelineStatusResponse: + status = await pipeline.get_pipeline_status(run_id, auth) if status is None: raise NotFoundError(NotFoundError.PIPELINE_RUN_MESSAGE) return status @@ -99,6 +103,6 @@ def get_pipeline_status(run_id: str, pipeline: PipelineServiceDI) -> PipelineSta @protected_router.post('/pipelines/status/{run_id}/save') async def save_pipeline_result( - run_id: str, save_request: PipelineSaveRequest, pipeline: PipelineServiceDI, auth: AuthenticatedDI + run_id: UUID, save_request: PipelineSaveRequest, pipeline: PipelineServiceDI, auth: AuthenticatedDI ) -> PipelineStatusResponse: return await pipeline.update_pipeline_result(run_id, save_request, auth) diff --git a/service/src/ai_document_plugin_service/api/types.py b/service/src/ai_document_plugin_service/api/types.py index 31a80e3..65203f1 100644 --- a/service/src/ai_document_plugin_service/api/types.py +++ b/service/src/ai_document_plugin_service/api/types.py @@ -22,7 +22,6 @@ class ErrorType(StrEnum): class PipelineStatus(StrEnum): - ACCEPTED = 'accepted' QUEUED = 'queued' RUNNING = 'running' SUCCEEDED = 'succeeded' @@ -72,14 +71,6 @@ class PipelineRunRequest(ApiModel): llm_max_workers: int | None = Field(default=None, alias='llmMaxWorkers', ge=1) -class PipelineRunResponse(ApiModel): - status: PipelineStatus - run_id: str = Field(alias='runId') - questionnaire_uuid: UUID = Field(alias='questionnaireUuid') - template_uuid: UUID = Field(alias='templateUuid') - template_title: str = Field(alias='templateTitle') - - class PipelineSaveRequest(ApiModel): result_markdown: str = Field(alias='resultMarkdown') @@ -89,13 +80,23 @@ class PipelineErrorResponse(ApiModel): message: str +class PipelineSummaryResponse(ApiModel): + run_id: UUID = Field(alias='runId') + status: PipelineStatus + title: str = Field(alias='templateTitle') + error: PipelineErrorResponse | None = None + progress_message: str | None = Field(default=None, alias='progressMessage') + created_at: str = Field(alias='createdAt') + updated_at: str = Field(alias='updatedAt') + + class PipelineStatusResponse(ApiModel): - run_id: str = Field(alias='runId') + run_id: UUID = Field(alias='runId') status: PipelineStatus questionnaire_uuid: UUID = Field(alias='questionnaireUuid') knowledge_model_uuid: UUID | None = Field(default=None, alias='knowledgeModelUuid') template_uuid: UUID = Field(alias='templateUuid') - template_title: str = Field(alias='templateTitle') + title: str = Field(alias='templateTitle') error: PipelineErrorResponse | None = None result_format: str | None = Field(default=None, alias='resultFormat') result_markdown: str | None = Field(default=None, alias='resultMarkdown') diff --git a/service/src/ai_document_plugin_service/migrations/versions/20260807_01_create_generation_table.py b/service/src/ai_document_plugin_service/migrations/versions/20260807_01_create_generation_table.py new file mode 100644 index 0000000..6f59ec4 --- /dev/null +++ b/service/src/ai_document_plugin_service/migrations/versions/20260807_01_create_generation_table.py @@ -0,0 +1,80 @@ +"""Create generation table. + +Persists pipeline run status and history (previously only kept in an +in-memory, per-process store), scoped per user so each user's project tab +only ever lists their own generations. + +Revision ID: 20260807_01 +Revises: 20260720_01 +Create Date: 2026-08-07 00:00:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import context, op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '20260807_01' +down_revision = '20260720_01' +branch_labels = None +depends_on = None + + +def _qualified_column_reference(schema: str | None, table: str, column: str) -> str: + if schema: + return f'{schema}.{table}.{column}' + return f'{table}.{column}' + + +def upgrade() -> None: + schema = context.get_context().version_table_schema + + op.create_table( + 'generation', + sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('questionnaire_uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('template_uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('knowledge_model_uuid', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('user_uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('tenant_uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('error_type', sa.Text(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('result_markdown', sa.Text(), nullable=True), + sa.Column('progress_message', sa.Text(), nullable=True), + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + 'updated_at', + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ['template_uuid'], + [_qualified_column_reference(schema, 'template', 'uuid')], + name='fk_generation_template_uuid', + ), + sa.PrimaryKeyConstraint('run_id', name='pk_generation'), + schema=schema, + ) + + op.create_index( + 'ix_generation_questionnaire_user_tenant_created_at', + 'generation', + ['questionnaire_uuid', 'user_uuid', 'tenant_uuid', 'created_at'], + schema=schema, + ) + + +def downgrade() -> None: + schema = context.get_context().version_table_schema + op.drop_index('ix_generation_questionnaire_user_tenant_created_at', 'generation', schema=schema) + op.drop_table('generation', schema=schema) diff --git a/service/src/ai_document_plugin_service/service/pipeline_queue_manager.py b/service/src/ai_document_plugin_service/service/pipeline_queue_manager.py index 18d7e67..710f4e6 100644 --- a/service/src/ai_document_plugin_service/service/pipeline_queue_manager.py +++ b/service/src/ai_document_plugin_service/service/pipeline_queue_manager.py @@ -1,3 +1,5 @@ +from uuid import UUID + import asyncio import logging import threading @@ -27,7 +29,7 @@ class PipelineQueueManager: def __init__(self, max_concurrent_jobs: int) -> None: self._max_concurrent_jobs = max_concurrent_jobs - self._order: list[str] = [] + self._order: list[UUID] = [] self._order_lock = threading.Lock() self._semaphore = asyncio.Semaphore(max_concurrent_jobs) self._loop = asyncio.new_event_loop() @@ -42,25 +44,25 @@ def _run_loop(self) -> None: asyncio.set_event_loop(self._loop) self._loop.run_forever() - def enqueue(self, run_id: str, job: JobFactory) -> None: + def enqueue(self, run_id: UUID, job: JobFactory) -> None: with self._order_lock: self._order.append(run_id) future = asyncio.run_coroutine_threadsafe(self._run_job(run_id, job), self._loop) future.add_done_callback(self._log_job_failure) - def progress_message(self, run_id: str) -> str | None: + def progress_message(self, run_id: UUID) -> str | None: jobs_waiting_ahead = self._jobs_waiting_ahead(run_id) if jobs_waiting_ahead is None: return None return format_queue_progress(jobs_waiting_ahead) - def remove(self, run_id: str) -> None: + def remove(self, run_id: UUID) -> None: with self._order_lock: if run_id in self._order: self._order.remove(run_id) - def _jobs_waiting_ahead(self, run_id: str) -> int | None: + def _jobs_waiting_ahead(self, run_id: UUID) -> int | None: with self._order_lock: try: queue_index = self._order.index(run_id) @@ -68,7 +70,7 @@ def _jobs_waiting_ahead(self, run_id: str) -> int | None: return None return queue_index - self._max_concurrent_jobs - async def _run_job(self, run_id: str, job: JobFactory) -> None: + async def _run_job(self, run_id: UUID, job: JobFactory) -> None: try: async with self._semaphore: await job() diff --git a/service/src/ai_document_plugin_service/service/pipeline_service.py b/service/src/ai_document_plugin_service/service/pipeline_service.py index 7e9892a..2aa2ed4 100644 --- a/service/src/ai_document_plugin_service/service/pipeline_service.py +++ b/service/src/ai_document_plugin_service/service/pipeline_service.py @@ -1,6 +1,7 @@ +import asyncio import logging import threading -from datetime import UTC, datetime +from asyncio import Task from uuid import UUID from openai import AuthenticationError @@ -12,7 +13,7 @@ from ai_document_plugin_service.ai.common.llm_client import LLMClient from ai_document_plugin_service.ai.knowledgemodel.dsw_client import DSWClient from ai_document_plugin_service.ai.persistence.assignment_saver_component import DBSaver -from ai_document_plugin_service.ai.persistence.database import Database +from ai_document_plugin_service.ai.persistence.database import Database, GenerationRecord from ai_document_plugin_service.ai.run_pipeline import build_pipeline, run_pipeline from ai_document_plugin_service.api.auth import AuthenticatedUser from ai_document_plugin_service.api.types import ( @@ -22,6 +23,7 @@ PipelineSaveRequest, PipelineStatus, PipelineStatusResponse, + PipelineSummaryResponse, _model_from_fields, ) from ai_document_plugin_service.service.errors import InternalError, NotFoundError @@ -47,8 +49,40 @@ def _pipeline_error_from_exception(error: Exception) -> PipelineErrorResponse: ) -def _now() -> str: - return datetime.now(tz=UTC).isoformat() +def _generation_error(record: GenerationRecord) -> PipelineErrorResponse | None: + if record.error_type is None or record.error_message is None: + return None + return PipelineErrorResponse(type=ErrorType(record.error_type), message=record.error_message) + + +def _generation_record_to_status_response(record: GenerationRecord) -> PipelineStatusResponse: + return _model_from_fields( + PipelineStatusResponse, + run_id=record.run_id, + status=PipelineStatus(record.status), + questionnaire_uuid=record.questionnaire_uuid, + knowledge_model_uuid=record.knowledge_model_uuid, + template_uuid=record.template_uuid, + title=record.title, + error=_generation_error(record), + result_format='markdown' if record.result_markdown is not None else None, + result_markdown=record.result_markdown, + progress_message=record.progress_message, + updated_at=record.updated_at.isoformat(), + ) + + +def _generation_record_to_summary_response(record: GenerationRecord) -> PipelineSummaryResponse: + return _model_from_fields( + PipelineSummaryResponse, + run_id=record.run_id, + status=PipelineStatus(record.status), + title=record.title, + error=_generation_error(record), + progress_message=record.progress_message, + created_at=record.created_at.isoformat(), + updated_at=record.updated_at.isoformat(), + ) class LlmClientTenantStore: @@ -72,69 +106,58 @@ def get_llm_client(self, tenant_uuid: UUID) -> LLMClient: return self._clients[tenant_uuid] -class PipelineRunStore: - """Thread-safe in-memory store of pipeline run statuses.""" - - def __init__(self) -> None: - self._runs: dict[str, PipelineStatusResponse] = {} - self._lock = threading.Lock() - - def get(self, run_id: str) -> PipelineStatusResponse | None: - with self._lock: - return self._runs.get(run_id) - - def set(self, run_id: str, status: PipelineStatusResponse) -> None: - with self._lock: - self._runs[run_id] = status - - def update(self, run_id: str, **updates: object) -> PipelineStatusResponse | None: - """Store a copy of the current status with ``updates`` applied and a fresh ``updated_at``.""" - with self._lock: - current = self._runs.get(run_id) - if current is None: - return None - status = current.model_copy(update={**updates, 'updated_at': _now()}) - self._runs[run_id] = status - return status +def _log_background_update_failure(task: Task[object]) -> None: + # Progress-message updates are best-effort and fired without awaiting; this guards + # against an unhandled exception being silently swallowed. + if task.cancelled(): + return + error = task.exception() + if error is not None: + logger.error('Failed to persist pipeline progress message', exc_info=error) class PipelineService: def __init__(self, pipeline_queue_manager: PipelineQueueManager, database: Database) -> None: self.pipeline_queue_manager = pipeline_queue_manager self.database = database - self._runs = PipelineRunStore() self._llm_clients = LlmClientTenantStore() - def get_pipeline_status(self, run_id: str) -> PipelineStatusResponse | None: - status = self._runs.get(run_id) - if status is None or status.status != PipelineStatus.QUEUED: + async def get_pipeline_status(self, run_id: UUID, auth: AuthenticatedUser) -> PipelineStatusResponse | None: + record = await self.database.get_generation(run_id, auth.tenant_uuid, auth.user_uuid) + if record is None: + return None + + status = _generation_record_to_status_response(record) + if status.status != PipelineStatus.QUEUED: return status + # Handle queued progress message (x dmps ahead in queue) progress_message = self.pipeline_queue_manager.progress_message(run_id) if progress_message is None: return status return status.model_copy(update={'progress_message': progress_message}) - def enqueue_pipeline_job( + async def list_history(self, questionnaire_uuid: UUID, auth: AuthenticatedUser) -> list[PipelineSummaryResponse]: + records = await self.database.list_generations(questionnaire_uuid, auth.tenant_uuid, auth.user_uuid) + return [_generation_record_to_summary_response(record) for record in records] + + async def enqueue_pipeline_job( self, - run_id: str, payload: PipelineRunRequest, - template_title: str, + title: str, auth: AuthenticatedUser, config: Config, - ) -> None: + ) -> UUID: """Queue a pipeline job; concurrency is limited by ``pipeline_queue_manager``.""" - run = _model_from_fields( - PipelineStatusResponse, - run_id=run_id, - status=PipelineStatus.QUEUED, + run_id = await self.database.create_generation( questionnaire_uuid=payload.questionnaire_uuid, template_uuid=payload.template_uuid, - template_title=template_title, - updated_at=_now(), + title=title, + user_uuid=auth.user_uuid, + tenant_uuid=auth.tenant_uuid, + status=PipelineStatus.QUEUED, ) - self._runs.set(run_id, run) llm_config = LLMConfig( model=payload.llm_model, @@ -144,76 +167,94 @@ def enqueue_pipeline_job( ) self.pipeline_queue_manager.enqueue( run_id, - lambda: self._run_pipeline_job(run, auth, llm_config, config), + lambda: self._run_pipeline_job( + run_id, + payload.questionnaire_uuid, + payload.template_uuid, + auth, + llm_config, + config, + ), ) + return run_id async def update_pipeline_result( - self, run_id: str, save_request: PipelineSaveRequest, auth: AuthenticatedUser + self, run_id: UUID, save_request: PipelineSaveRequest, auth: AuthenticatedUser ) -> PipelineStatusResponse: - pipeline_status = self.get_pipeline_status(run_id) - if pipeline_status is None: + record = await self.database.get_generation(run_id, auth.tenant_uuid, auth.user_uuid) + if record is None: raise NotFoundError(NotFoundError.PIPELINE_RUN_MESSAGE) - if pipeline_status.knowledge_model_uuid is None: + if record.knowledge_model_uuid is None: raise InternalError(InternalError.MISSING_KNOWLEDGE_MODEL_MESSAGE) await self.database.update_result( - template_uuid=pipeline_status.template_uuid, - knowledge_model_uuid=pipeline_status.knowledge_model_uuid, + template_uuid=record.template_uuid, + knowledge_model_uuid=record.knowledge_model_uuid, user_uuid=auth.user_uuid, tenant_uuid=auth.tenant_uuid, markdown=save_request.result_markdown, ) - updated_status = self._runs.update( + updated_record = await self.database.update_generation( run_id, - result_format='markdown', + auth.tenant_uuid, result_markdown=save_request.result_markdown, progress_message=None, ) - if updated_status is None: + if updated_record is None: raise NotFoundError(NotFoundError.PIPELINE_RUN_MESSAGE) - return updated_status + return _generation_record_to_status_response(updated_record) async def _run_pipeline_job( self, - run: PipelineStatusResponse, + run_id: UUID, + questionnaire_uuid: UUID, + template_uuid: UUID, auth: AuthenticatedUser, llm_config: LLMConfig, config: Config, ) -> None: try: - await self._run_pipeline(run, auth, llm_config, config) + await self._run_pipeline(run_id, questionnaire_uuid, template_uuid, auth, llm_config, config) except Exception as error: logger.exception('Pipeline run failed') - self._runs.update( - run.run_id, + pipeline_error = _pipeline_error_from_exception(error) + await self.database.update_generation( + run_id, + auth.tenant_uuid, status=PipelineStatus.FAILED, - error=_pipeline_error_from_exception(error), + error_type=pipeline_error.type, + error_message=pipeline_error.message, progress_message=None, ) async def _run_pipeline( self, - run: PipelineStatusResponse, + run_id: UUID, + questionnaire_uuid: UUID, + template_uuid: UUID, auth: AuthenticatedUser, llm_config: LLMConfig, config: Config, ) -> None: - run_id = run.run_id - template = await self.database.get_template(run.template_uuid, auth.tenant_uuid) + template = await self.database.get_template(template_uuid, auth.tenant_uuid) if template is None: - self._runs.update( + await self.database.update_generation( run_id, + auth.tenant_uuid, status=PipelineStatus.FAILED, - error=PipelineErrorResponse( - type=ErrorType.TEMPLATE_NOT_FOUND, - message=TEMPLATE_NOT_FOUND_MESSAGE, - ), + error_type=ErrorType.TEMPLATE_NOT_FOUND, + error_message=TEMPLATE_NOT_FOUND_MESSAGE, ) return - self._runs.update(run_id, status=PipelineStatus.RUNNING, progress_message='Starting pipeline...') + await self.database.update_generation( + run_id, + auth.tenant_uuid, + status=PipelineStatus.RUNNING, + progress_message='Starting pipeline...', + ) llm_client = self._llm_clients.get_llm_client(auth.tenant_uuid) llm_client.update_config(llm_config.model, llm_config.api_key, llm_config.api_url, llm_config.parallel_workers) @@ -225,11 +266,16 @@ async def _run_pipeline( ) def on_progress(message: str) -> None: - self._runs.update(run_id, progress_message=message) + # Called synchronously from deep inside the (async) pipeline; fire the DB + # write without awaiting it so progress reporting never blocks generation. + task = asyncio.ensure_future( + self.database.update_generation(run_id, auth.tenant_uuid, progress_message=message), + ) + task.add_done_callback(_log_background_update_failure) knowledge_model_uuid, result = await run_pipeline( - questionnaire_uuid=run.questionnaire_uuid, - template_uuid=run.template_uuid, + questionnaire_uuid=questionnaire_uuid, + template_uuid=template_uuid, template_title=template.title, template_data=template.content, user_uuid=auth.user_uuid, @@ -241,11 +287,11 @@ def on_progress(message: str) -> None: dsw_client=DSWClient(auth.token, auth.api_url), ) - self._runs.update( + await self.database.update_generation( run_id, + auth.tenant_uuid, status=PipelineStatus.SUCCEEDED, knowledge_model_uuid=knowledge_model_uuid, - result_format='markdown', result_markdown=result, progress_message=None, ) From 225a023ca82e329440d1011f8196c6176b03e8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 7 Aug 2026 13:04:26 +0200 Subject: [PATCH 02/14] Remove model from fields helper to improve IDE linking of variables --- .../ai_document_plugin_service/api/types.py | 56 +++++++++---------- .../service/pipeline_service.py | 11 ++-- .../service/template_service.py | 13 ++--- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/service/src/ai_document_plugin_service/api/types.py b/service/src/ai_document_plugin_service/api/types.py index 65203f1..9158b72 100644 --- a/service/src/ai_document_plugin_service/api/types.py +++ b/service/src/ai_document_plugin_service/api/types.py @@ -2,17 +2,15 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field - - -def _model_from_fields[T: ApiModel]( - model_type: type[T], - **data: object, -) -> T: - return model_type.model_validate(data) +from pydantic.alias_generators import to_camel class ApiModel(BaseModel): - model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + model_config = ConfigDict( + populate_by_name=True, + alias_generator=to_camel, + serialize_by_alias=True, + ) class ErrorType(StrEnum): @@ -63,16 +61,16 @@ class TemplateUpdateRequest(ApiModel): class PipelineRunRequest(ApiModel): - questionnaire_uuid: UUID = Field(alias='questionnaireUuid') - template_uuid: UUID = Field(alias='templateUuid') - llm_model: str = Field(alias='llmModel') - llm_api_key: str = Field(alias='llmApiKey') - llm_api_url: str = Field(alias='llmApiUrl') - llm_max_workers: int | None = Field(default=None, alias='llmMaxWorkers', ge=1) + questionnaire_uuid: UUID + template_uuid: UUID + llm_model: str + llm_api_key: str + llm_api_url: str + llm_max_workers: int | None = Field(default=None, ge=1) class PipelineSaveRequest(ApiModel): - result_markdown: str = Field(alias='resultMarkdown') + result_markdown: str class PipelineErrorResponse(ApiModel): @@ -81,24 +79,24 @@ class PipelineErrorResponse(ApiModel): class PipelineSummaryResponse(ApiModel): - run_id: UUID = Field(alias='runId') + run_id: UUID status: PipelineStatus - title: str = Field(alias='templateTitle') + template_title: str error: PipelineErrorResponse | None = None - progress_message: str | None = Field(default=None, alias='progressMessage') - created_at: str = Field(alias='createdAt') - updated_at: str = Field(alias='updatedAt') + progress_message: str | None = None + created_at: str + updated_at: str class PipelineStatusResponse(ApiModel): - run_id: UUID = Field(alias='runId') + run_id: UUID status: PipelineStatus - questionnaire_uuid: UUID = Field(alias='questionnaireUuid') - knowledge_model_uuid: UUID | None = Field(default=None, alias='knowledgeModelUuid') - template_uuid: UUID = Field(alias='templateUuid') - title: str = Field(alias='templateTitle') + questionnaire_uuid: UUID + knowledge_model_uuid: UUID | None = None + template_uuid: UUID + template_title: str error: PipelineErrorResponse | None = None - result_format: str | None = Field(default=None, alias='resultFormat') - result_markdown: str | None = Field(default=None, alias='resultMarkdown') - progress_message: str | None = Field(default=None, alias='progressMessage') - updated_at: str = Field(alias='updatedAt') + result_format: str | None = None + result_markdown: str | None = None + progress_message: str | None = None + updated_at: str diff --git a/service/src/ai_document_plugin_service/service/pipeline_service.py b/service/src/ai_document_plugin_service/service/pipeline_service.py index 2aa2ed4..44e516e 100644 --- a/service/src/ai_document_plugin_service/service/pipeline_service.py +++ b/service/src/ai_document_plugin_service/service/pipeline_service.py @@ -24,7 +24,6 @@ PipelineStatus, PipelineStatusResponse, PipelineSummaryResponse, - _model_from_fields, ) from ai_document_plugin_service.service.errors import InternalError, NotFoundError from ai_document_plugin_service.service.pipeline_queue_manager import PipelineQueueManager @@ -56,14 +55,13 @@ def _generation_error(record: GenerationRecord) -> PipelineErrorResponse | None: def _generation_record_to_status_response(record: GenerationRecord) -> PipelineStatusResponse: - return _model_from_fields( - PipelineStatusResponse, + return PipelineStatusResponse( run_id=record.run_id, status=PipelineStatus(record.status), questionnaire_uuid=record.questionnaire_uuid, knowledge_model_uuid=record.knowledge_model_uuid, template_uuid=record.template_uuid, - title=record.title, + template_title=record.title, error=_generation_error(record), result_format='markdown' if record.result_markdown is not None else None, result_markdown=record.result_markdown, @@ -73,11 +71,10 @@ def _generation_record_to_status_response(record: GenerationRecord) -> PipelineS def _generation_record_to_summary_response(record: GenerationRecord) -> PipelineSummaryResponse: - return _model_from_fields( - PipelineSummaryResponse, + return PipelineSummaryResponse( run_id=record.run_id, status=PipelineStatus(record.status), - title=record.title, + template_title=record.title, error=_generation_error(record), progress_message=record.progress_message, created_at=record.created_at.isoformat(), diff --git a/service/src/ai_document_plugin_service/service/template_service.py b/service/src/ai_document_plugin_service/service/template_service.py index b3fb665..a734a8b 100644 --- a/service/src/ai_document_plugin_service/service/template_service.py +++ b/service/src/ai_document_plugin_service/service/template_service.py @@ -9,7 +9,6 @@ TemplateListItem, TemplateScope, TemplateUpdateRequest, - _model_from_fields, ) from ai_document_plugin_service.service.errors import ( AccessDeniedError, @@ -56,8 +55,7 @@ async def create(self, auth: AuthenticatedUser, payload: TemplateCreateRequest) except TemplateTitleConflictError as error: raise ConflictError(str(error)) from error - return _model_from_fields( - TemplateDetail, + return TemplateDetail( uuid=template_uuid, title=trimmed_title, content=payload.content, @@ -91,8 +89,7 @@ async def update( except TemplateTitleConflictError as error: raise ConflictError(str(error)) from error - return _model_from_fields( - TemplateDetail, + return TemplateDetail( uuid=template_uuid, title=trimmed_title, content=payload.content, @@ -122,8 +119,7 @@ def _can_mutate(auth: AuthenticatedUser, scope: TemplateScope, owner_uuid: UUID @staticmethod def _to_list_item(record: TemplateRecord) -> TemplateListItem: - return _model_from_fields( - TemplateListItem, + return TemplateListItem( uuid=str(record.uuid), title=record.title, scope=record.scope, @@ -131,8 +127,7 @@ def _to_list_item(record: TemplateRecord) -> TemplateListItem: @staticmethod def _to_detail(record: TemplateRecord) -> TemplateDetail: - return _model_from_fields( - TemplateDetail, + return TemplateDetail( uuid=record.uuid, title=record.title, content=record.content, From 59537f5fa1a1dae1fc20bb1b0088f84f9fc7fc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 7 Aug 2026 14:06:17 +0200 Subject: [PATCH 03/14] Added frontend --- plugin/src/client.ts | 26 +- .../src/components/HistorySidebar.module.css | 115 ++++++++ plugin/src/components/HistorySidebar.tsx | 84 ++++++ plugin/src/components/PipelineResultPanel.tsx | 5 +- plugin/src/components/ProjectTab.module.css | 12 + plugin/src/components/ProjectTab.tsx | 89 +++--- .../src/components/RunDetailPanel.module.css | 16 ++ plugin/src/components/RunDetailPanel.tsx | 86 ++++++ plugin/src/hooks/useGenerationHistory.ts | 263 ++++++++++++++++++ plugin/src/hooks/usePipeline.ts | 173 ------------ plugin/src/runPoller.ts | 58 ++++ plugin/src/types.ts | 18 +- 12 files changed, 721 insertions(+), 224 deletions(-) create mode 100644 plugin/src/components/HistorySidebar.module.css create mode 100644 plugin/src/components/HistorySidebar.tsx create mode 100644 plugin/src/components/RunDetailPanel.module.css create mode 100644 plugin/src/components/RunDetailPanel.tsx create mode 100644 plugin/src/hooks/useGenerationHistory.ts delete mode 100644 plugin/src/hooks/usePipeline.ts create mode 100644 plugin/src/runPoller.ts diff --git a/plugin/src/client.ts b/plugin/src/client.ts index 253d845..e61a787 100644 --- a/plugin/src/client.ts +++ b/plugin/src/client.ts @@ -1,8 +1,8 @@ import { getApiUrlAndToken } from '@ds-wizard/plugin-sdk/requests' import type { - PipelineRunResponse, PipelineStatusResponse, + PipelineSummaryItem, TemplateDetail, TemplateOption, TemplateScope, @@ -103,6 +103,24 @@ export const getPipelineStatus = async (runId: string): Promise => { + const url = `${getApiBaseUrl()}/pipelines?questionnaireUuid=${encodeURIComponent(questionnaireUuid)}` + const response = await apiFetch(url) + const data = await readApiResponse(response, url) + + if (!response.ok) { + throw new Error( + 'detail' in data && data.detail ? data.detail : 'Failed to load the generation history.', + ) + } + + if (!Array.isArray(data)) { + throw new Error('Invalid generation history returned.') + } + + return data +} + type RunPipelineParams = { questionnaireUuid: string templateUuid: string @@ -119,7 +137,7 @@ export const runPipeline = async ({ llmApiKey = null, llmApiUrl = null, llmMaxWorkers = null, -}: RunPipelineParams): Promise => { +}: RunPipelineParams): Promise => { const url = `${getApiBaseUrl()}/pipelines/run` const response = await apiFetch(url, { method: 'POST', @@ -136,7 +154,7 @@ export const runPipeline = async ({ }), }) - const data = await readApiResponse(response, url) + const data = await readApiResponse(response, url) if (!response.ok) { if (response.status == 422) { @@ -150,7 +168,7 @@ export const runPipeline = async ({ ) } - if (!('runId' in data)) { + if (!isPipelineStatusResponse(data)) { throw new Error('The backend did not return a pipeline run identifier.') } diff --git a/plugin/src/components/HistorySidebar.module.css b/plugin/src/components/HistorySidebar.module.css new file mode 100644 index 0000000..c519b75 --- /dev/null +++ b/plugin/src/components/HistorySidebar.module.css @@ -0,0 +1,115 @@ +.root { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 17rem; + flex-shrink: 0; + position: sticky; + top: 0; + align-self: flex-start; + height: 100%; + padding: 1.5rem 0.75rem; + border-right: 1px solid rgba(0, 0, 0, 0.2); +} + +.newButton { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.7rem 0.9rem; + border: 1px dashed var(--ai-doc-color-slate-300); + border-radius: var(--bs-border-radius); + background: var(--ai-doc-color-white); + color: var(--ai-doc-color-slate-700); + font-weight: 600; + text-align: left; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.newButton:hover { + background: var(--bs-primary-bg); + border-color: var(--bs-primary); +} + +.list { + display: flex; + flex-direction: column; + flex: 1 1 0; + gap: 0.25rem; + min-height: 0; + overflow-y: auto; +} + +.item { + display: flex; + align-items: flex-start; + gap: 0.65rem; + width: 100%; + padding: 0.6rem 0.9rem; + border: 0; + border-radius: var(--bs-border-radius); + background: transparent; + color: var(--ai-doc-color-slate-900); + text-align: left; + transition: background-color 120ms ease; +} + +.item:hover { + background: var(--ai-doc-color-slate-100); +} + +.itemActive { + background: var(--bs-primary-bg); + color: var(--bs-primary); +} + +.itemBody { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.itemTitle { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemTime { + font-size: 0.78rem; + color: var(--ai-doc-color-slate-500); +} + +.statusIcon { + flex-shrink: 0; + margin-top: 0.2rem; + font-size: 0.85rem; +} + +.statusIcon.queued { + color: var(--ai-doc-color-orange-700); +} + +.statusIcon.running { + color: var(--bs-primary); +} + +.statusIcon.succeeded { + color: var(--ai-doc-color-emerald-700); +} + +.statusIcon.failed { + color: var(--ai-doc-color-red-700); +} + +.emptyState { + padding: 0.6rem 0.9rem; + color: var(--ai-doc-color-slate-500); + font-size: 0.85rem; +} diff --git a/plugin/src/components/HistorySidebar.tsx b/plugin/src/components/HistorySidebar.tsx new file mode 100644 index 0000000..e19aa77 --- /dev/null +++ b/plugin/src/components/HistorySidebar.tsx @@ -0,0 +1,84 @@ +import styles from '@/components/HistorySidebar.module.css' +import type { RunRecord, UseGenerationHistoryResult } from '@/hooks/useGenerationHistory' + +type HistorySidebarProps = { + history: UseGenerationHistoryResult + selectedRunId: string | null + onSelectNew: () => void + onSelectRun: (runId: string) => void +} + +const STATUS_ICON: Record = { + queued: 'fas fa-clock', + running: 'fas fa-spinner fa-spin', + succeeded: 'fas fa-check-circle', + failed: 'fas fa-exclamation-circle', +} + +const RELATIVE_TIME_DIVISIONS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 60 * 60 * 24 * 365], + ['month', 60 * 60 * 24 * 30], + ['day', 60 * 60 * 24], + ['hour', 60 * 60], + ['minute', 60], +] + +const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +const formatRelativeTime = (isoString: string): string => { + const date = new Date(isoString) + if (Number.isNaN(date.getTime())) { + return '' + } + + const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000) + + for (const [unit, secondsInUnit] of RELATIVE_TIME_DIVISIONS) { + if (Math.abs(diffSeconds) >= secondsInUnit) { + return relativeTimeFormatter.format(Math.round(diffSeconds / secondsInUnit), unit) + } + } + return relativeTimeFormatter.format(diffSeconds, 'second') +} + +export function HistorySidebar({ history, selectedRunId, onSelectNew, onSelectRun }: HistorySidebarProps) { + const { items, isLoading } = history + + return ( +