Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions publisher_v2/src/publisher_v2/core/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion publisher_v2/src/publisher_v2/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions publisher_v2/src/publisher_v2/web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AnalysisResponse(BaseModel):

class PublishRequest(BaseModel):
platforms: Optional[List[str]] = None
caption: Optional[str] = None


class PublishResponse(BaseModel):
Expand Down
24 changes: 18 additions & 6 deletions publisher_v2/src/publisher_v2/web/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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]] = {}
Expand Down
136 changes: 132 additions & 4 deletions publisher_v2/src/publisher_v2/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>
</head>
<body>
Expand Down Expand Up @@ -281,7 +317,13 @@ <h1 id="app-title">{{ web_ui_text.header_title or "Publisher V2 Web" }}</h1>

<div class="panel" id="panel-caption">
<h2>{{ web_ui_text.panels.caption_title or "Caption" }}</h2>
<div id="caption-text">{{ web_ui_text.placeholders.caption_empty or "No caption yet." }}</div>
<textarea id="caption-text" rows="4"
placeholder="{{ web_ui_text.placeholders.caption_empty or 'No caption yet.' }}"
readonly></textarea>
<div class="caption-meta">
<span id="caption-edited" class="edited-badge hidden">(edited)</span>
<span id="caption-char-count" class="char-count"></span>
</div>
</div>

<div class="panel admin-only hidden" id="panel-admin">
Expand Down Expand Up @@ -612,11 +654,38 @@ <h2>{{ web_ui_text.admin_dialog.title or "Admin Login" }}</h2>
detailsEl.innerHTML = html || "";
}

let originalCaption = "";

function setCaption(text) {
const fallback = TEXT.placeholders?.caption_empty || "No caption yet.";
captionEl.textContent = text || fallback;
captionEl.value = text || fallback;
captionEl.readOnly = !text;
originalCaption = text || "";
updateCaptionMeta();
}

function updateCaptionMeta() {
const len = captionEl.value.trim().length;
const maxLen = 240;
const countEl = document.getElementById("caption-char-count");
const editedEl = document.getElementById("caption-edited");
if (countEl) {
if (!originalCaption) {
countEl.textContent = "";
countEl.classList.remove("over-limit");
} else {
countEl.textContent = `${len} / ${maxLen}`;
countEl.classList.toggle("over-limit", len > maxLen);
}
}
if (editedEl) {
const isEdited = captionEl.value !== originalCaption && originalCaption !== "";
editedEl.classList.toggle("hidden", !isEdited);
}
}

captionEl.addEventListener("input", updateCaptionMeta);

function showImagePlaceholder(message) {
if (imgEl) {
imgEl.src = "";
Expand Down Expand Up @@ -899,6 +968,55 @@ <h2>{{ web_ui_text.admin_dialog.title or "Admin Login" }}</h2>
}
}

function showPublishConfirm(caption, isPlaceholder) {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal";
const dialog = document.createElement("div");
dialog.className = "modal-dialog";

const heading = document.createElement("h2");
heading.textContent = isPlaceholder
? "Publish without a reviewed caption?"
: "Publish with this caption?";

const preview = document.createElement("p");
if (isPlaceholder) {
preview.style.color = "#9ca3af";
preview.textContent = "AI will generate a new caption at publish time.";
} else {
preview.style.whiteSpace = "pre-wrap";
preview.style.wordBreak = "break-word";
preview.style.maxHeight = "8rem";
preview.style.overflowY = "auto";
preview.textContent = caption.length > 300
? caption.substring(0, 300) + "..."
: caption;
}

const footer = document.createElement("div");
footer.className = "modal-footer";
const cancelBtn = document.createElement("button");
cancelBtn.className = "secondary";
cancelBtn.textContent = "Cancel";
const publishBtn = document.createElement("button");
publishBtn.textContent = "Confirm & Publish";
footer.appendChild(cancelBtn);
footer.appendChild(publishBtn);

dialog.appendChild(heading);
dialog.appendChild(preview);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);

const cleanup = (val) => { overlay.remove(); resolve(val); };
cancelBtn.addEventListener("click", () => cleanup(false));
publishBtn.addEventListener("click", () => cleanup(true));
overlay.addEventListener("click", (e) => { if (e.target === overlay) cleanup(false); });
});
}

async function apiPublish() {
if (!currentFilename) {
setActivity("No image selected.");
Expand All @@ -908,13 +1026,23 @@ <h2>{{ web_ui_text.admin_dialog.title or "Admin Login" }}</h2>
setActivity("Admin mode required to publish.");
return;
}
setActivity("Publishing image…");

const caption = captionEl.value.trim();
const isPlaceholder = captionEl.readOnly || !caption;

const confirmed = await showPublishConfirm(caption, isPlaceholder);
if (!confirmed) {
setActivity("Publish cancelled.");
return;
}

setActivity("Publishing image\u2026");
disableButtons(true);
try {
const res = await fetch(`/api/images/${encodeURIComponent(currentFilename)}/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
body: JSON.stringify({ caption: isPlaceholder ? null : caption })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
Expand Down
76 changes: 74 additions & 2 deletions publisher_v2/tests/test_workflow_feature_toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,21 @@ async def generate(self, analysis: ImageAnalysis, spec) -> str:


class _StubPublisher(Publisher):
def __init__(self) -> None:
def __init__(self, name: str = "stub") -> None:
self.called = False
self.received_caption: str | None = None
self._name = name

@property
def platform_name(self) -> str:
return "stub"
return self._name

def is_enabled(self) -> bool:
return True

async def publish(self, image_path: str, caption: str, context: dict | None = None) -> PublishResult:
self.called = True
self.received_caption = caption
return PublishResult(success=True, platform=self.platform_name)


Expand Down Expand Up @@ -162,3 +165,72 @@ async def test_workflow_default_toggles_execute_all_steps(monkeypatch: pytest.Mo
assert publisher.called is True
assert result.publish_results


# --- Caption override tests ---


@pytest.mark.asyncio
async def test_caption_override_skips_ai_and_uses_provided_caption(monkeypatch: pytest.MonkeyPatch) -> None:
publisher = _StubPublisher()
orchestrator, cfg, _, _, generator, _ = _make_orchestrator(monkeypatch, [publisher])
cfg.content.debug = False

result = await orchestrator.execute(caption_override="User approved caption")

assert generator.calls == 0
assert publisher.called is True
assert publisher.received_caption is not None
assert "User approved caption" in publisher.received_caption


@pytest.mark.asyncio
async def test_caption_override_empty_string_falls_through_to_ai(monkeypatch: pytest.MonkeyPatch) -> None:
publisher = _StubPublisher()
orchestrator, cfg, _, _, generator, _ = _make_orchestrator(monkeypatch, [publisher])
cfg.content.debug = False

await orchestrator.execute(caption_override="")

assert generator.calls >= 1


@pytest.mark.asyncio
async def test_caption_override_whitespace_only_falls_through_to_ai(monkeypatch: pytest.MonkeyPatch) -> None:
publisher = _StubPublisher()
orchestrator, cfg, _, _, generator, _ = _make_orchestrator(monkeypatch, [publisher])
cfg.content.debug = False

await orchestrator.execute(caption_override=" ")

assert generator.calls >= 1


@pytest.mark.asyncio
async def test_caption_override_is_sanitized_by_format_caption(monkeypatch: pytest.MonkeyPatch) -> None:
"""Override with em-dash and hashtag is sanitized for the email platform."""
publisher = _StubPublisher(name="email")
orchestrator, cfg, _, _, generator, _ = _make_orchestrator(monkeypatch, [publisher])
cfg.content.debug = False
cfg.platforms.email_enabled = True

await orchestrator.execute(caption_override="Hello #world \u2014 beautiful day")

assert generator.calls == 0
assert publisher.called is True
assert publisher.received_caption is not None
assert "#world" not in publisher.received_caption
assert " - " in publisher.received_caption


@pytest.mark.asyncio
async def test_caption_override_none_preserves_existing_behavior(monkeypatch: pytest.MonkeyPatch) -> None:
publisher = _StubPublisher()
orchestrator, cfg, _, _, generator, _ = _make_orchestrator(monkeypatch, [publisher])
cfg.content.debug = False

result = await orchestrator.execute(caption_override=None)

assert generator.calls >= 1
assert publisher.called is True
assert result.caption != ""

2 changes: 1 addition & 1 deletion publisher_v2/tests/web/test_web_app_additional.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async def analyze_and_caption(self, filename: str, *, correlation_id: str, force
sidecar_written=False,
)

async def publish_image(self, filename: str, platforms: list[str] | None):
async def publish_image(self, filename: str, platforms: list[str] | None, caption_override: str | None = None):
if self.publish_error:
raise self.publish_error
return SimpleNamespace(filename=filename, results={}, archived=False, any_success=False)
Expand Down
2 changes: 1 addition & 1 deletion publisher_v2/tests/web/test_web_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async def create_caption(self, url_or_bytes: str | bytes, spec: Any) -> str:


class _DummyOrchestrator:
async def execute(self, select_filename: str | None = None, dry_publish: bool = False, preview_mode: bool = False):
async def execute(self, select_filename: str | None = None, dry_publish: bool = False, preview_mode: bool = False, caption_override: str | None = None):
from publisher_v2.core.models import WorkflowResult, PublishResult

return WorkflowResult(
Expand Down
Loading