From 98056a99c4008045530d64ab900701df535b5f5b Mon Sep 17 00:00:00 2001 From: Evert Smit Date: Wed, 25 Feb 2026 15:38:13 +0100 Subject: [PATCH] feat(web): caption approval before FetLife email publish Allow web UI users to review, edit, and confirm AI-generated captions before publishing to FetLife via email. Previously the publish action re-ran caption generation from scratch, discarding the displayed caption. - Add caption_override parameter to WorkflowOrchestrator.execute() - Thread optional caption through PublishRequest, route, and service - Replace read-only caption div with editable textarea - Add live character counter (N/240) and "(edited)" indicator - Add inline confirmation modal before publish (XSS-safe, DOM API) - Normalize whitespace-only captions to None (falls through to AI) - Add 5 tests covering override, fallthrough, and sanitization Co-authored-by: Cursor --- .../src/publisher_v2/core/workflow.py | 16 ++- publisher_v2/src/publisher_v2/web/app.py | 4 +- publisher_v2/src/publisher_v2/web/models.py | 1 + publisher_v2/src/publisher_v2/web/service.py | 24 +++- .../src/publisher_v2/web/templates/index.html | 136 +++++++++++++++++- .../tests/test_workflow_feature_toggles.py | 76 +++++++++- .../tests/web/test_web_app_additional.py | 2 +- publisher_v2/tests/web/test_web_service.py | 2 +- 8 files changed, 244 insertions(+), 17 deletions(-) diff --git a/publisher_v2/src/publisher_v2/core/workflow.py b/publisher_v2/src/publisher_v2/core/workflow.py index 0cbb829..e15fb22 100644 --- a/publisher_v2/src/publisher_v2/core/workflow.py +++ b/publisher_v2/src/publisher_v2/core/workflow.py @@ -48,7 +48,8 @@ async def execute( self, select_filename: str | None = None, dry_publish: bool = False, - preview_mode: bool = False + preview_mode: bool = False, + caption_override: str | None = None, ) -> WorkflowResult: correlation_id = str(uuid.uuid4()) selected_image = "" @@ -322,7 +323,18 @@ def _log_timing() -> None: ) caption = "" sd_caption = None - if self.config.features.analyze_caption_enabled: + if caption_override and caption_override.strip(): + caption = caption_override + sd_caption = None + log_json( + self.logger, + logging.INFO, + "caption_override_used", + override_length=len(caption), + ai_skipped=True, + correlation_id=correlation_id, + ) + elif self.config.features.analyze_caption_enabled: if not preview_mode: log_json(self.logger, logging.INFO, "caption_generation_start", correlation_id=correlation_id) caption_start = now_monotonic() diff --git a/publisher_v2/src/publisher_v2/web/app.py b/publisher_v2/src/publisher_v2/web/app.py index cea059f..30c9851 100644 --- a/publisher_v2/src/publisher_v2/web/app.py +++ b/publisher_v2/src/publisher_v2/web/app.py @@ -442,8 +442,10 @@ async def api_publish_image( if is_admin_configured(): require_admin(request) platforms = body.platforms if body else None + raw_caption = body.caption if body else None + caption_override = raw_caption.strip() if raw_caption and raw_caption.strip() else None try: - resp = await service.publish_image(filename, platforms) + resp = await service.publish_image(filename, platforms, caption_override=caption_override) web_publish_ms = elapsed_ms(telemetry.start_time) response.headers["X-Correlation-ID"] = telemetry.correlation_id log_json( diff --git a/publisher_v2/src/publisher_v2/web/models.py b/publisher_v2/src/publisher_v2/web/models.py index e31fd8d..8ad7e1d 100644 --- a/publisher_v2/src/publisher_v2/web/models.py +++ b/publisher_v2/src/publisher_v2/web/models.py @@ -34,6 +34,7 @@ class AnalysisResponse(BaseModel): class PublishRequest(BaseModel): platforms: Optional[List[str]] = None + caption: Optional[str] = None class PublishResponse(BaseModel): diff --git a/publisher_v2/src/publisher_v2/web/service.py b/publisher_v2/src/publisher_v2/web/service.py index 7222a97..c65a7b9 100644 --- a/publisher_v2/src/publisher_v2/web/service.py +++ b/publisher_v2/src/publisher_v2/web/service.py @@ -497,12 +497,20 @@ async def analyze_and_caption( sidecar_written=sidecar_written, ) - async def publish_image(self, filename: str, platforms: Optional[List[str]] = None) -> PublishResponse: + async def publish_image( + self, + filename: str, + platforms: Optional[List[str]] = None, + caption_override: Optional[str] = None, + ) -> PublishResponse: """ Publish a specific image by delegating to the existing WorkflowOrchestrator. Platforms list is currently advisory only; for MVP we respect the enabled flags from config and still reuse the orchestrator behaviour. + + When caption_override is provided, the orchestrator skips AI caption + generation and uses the caller-supplied text instead. """ if not self.config.features.publish_enabled: log_json( @@ -513,11 +521,14 @@ async def publish_image(self, filename: str, platforms: Optional[List[str]] = No ) raise PermissionError("Publish feature is disabled via FEATURE_PUBLISH toggle") - # The orchestrator will: - # - re-select the filename - # - re-run analysis/caption if needed - # - publish and archive on success - # We call it with dry_publish=False and preview_mode=False. + if caption_override: + log_json( + self.logger, + logging.INFO, + "web_publish_caption_override", + image=filename, + override_length=len(caption_override), + ) # Resolve optional publisher secrets lazily (telegram/smtp) before publishing. await self._ensure_publishers() @@ -527,6 +538,7 @@ async def publish_image(self, filename: str, platforms: Optional[List[str]] = No select_filename=filename, dry_publish=False, preview_mode=False, + caption_override=caption_override, ) # Convert results to simple dict form results: Dict[str, Dict[str, Any]] = {} diff --git a/publisher_v2/src/publisher_v2/web/templates/index.html b/publisher_v2/src/publisher_v2/web/templates/index.html index f81b570..fc75013 100644 --- a/publisher_v2/src/publisher_v2/web/templates/index.html +++ b/publisher_v2/src/publisher_v2/web/templates/index.html @@ -230,6 +230,42 @@ gap: 0.5rem; margin-top: 0.75rem; } + #caption-text { + width: 100%; + background: #111827; + color: #e5e7eb; + border: 1px solid #374151; + border-radius: 0.5rem; + padding: 0.5rem; + font-family: inherit; + font-size: 0.9rem; + resize: vertical; + min-height: 4rem; + } + #caption-text:focus { + outline: none; + border-color: #38bdf8; + } + #caption-text[readonly] { + opacity: 0.6; + cursor: default; + } + .caption-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.35rem; + font-size: 0.8rem; + color: #9ca3af; + } + .caption-meta .char-count.over-limit { + color: #ef4444; + font-weight: 600; + } + .caption-meta .edited-badge { + color: #facc15; + font-weight: 500; + } @@ -281,7 +317,13 @@

{{ web_ui_text.header_title or "Publisher V2 Web" }}

{{ web_ui_text.panels.caption_title or "Caption" }}

-
{{ web_ui_text.placeholders.caption_empty or "No caption yet." }}
+ +
+ + +