Add conversational brief interface (AC1 chat-style)

Replaces the one-shot form with a Copygen-style chat at
/conversations/:id. Each turn is classified as generate / refine /
clarify by a lightweight LLM intent router; generate and refine intents
enqueue an RQ job that produces a new BannerSet, while clarify returns
an inline reply without touching banners.

New backend:
- Conversation + ConversationMessage models + migration 0005
- intent_router service (chat_structured, 3-intent schema)
- chat_turn RQ task with _persist_banner_set helper extracted from
  _generate_copy_async for reuse
- /api/conversations CRUD + POST /messages endpoint
- JobType.CHAT_TURN added

New frontend:
- ChatBrief page: message bubbles, inline BannerPreview cards with
  checkbox selection and "Open banner editor" CTA (same Medium+Large
  validation rule as VariantsGrid)
- ConversationLanding: /conversations/new creates and redirects
- conversationId added to journey store
- "New Brief" nav now points to /conversations/new
- Default route redirects to /conversations/new

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-28 21:53:17 +01:00
parent d9456d80be
commit 7e82a535a9
15 changed files with 1020 additions and 61 deletions

View file

@ -0,0 +1,51 @@
"""Add conversations and conversation_messages tables
Revision ID: 0005
Revises: 0004
Create Date: 2026-04-28
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"conversations",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("brief_id", UUID(as_uuid=True), sa.ForeignKey("briefs.id"), nullable=False),
sa.Column("title", sa.String(120), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_conversations_user_id", "conversations", ["user_id"])
op.create_table(
"conversation_messages",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("conversation_id", UUID(as_uuid=True), sa.ForeignKey("conversations.id"), nullable=False),
sa.Column("role", sa.String(20), nullable=False),
sa.Column("kind", sa.String(20), nullable=False, server_default="text"),
sa.Column("content", sa.Text, nullable=False, server_default=""),
sa.Column("banner_set_id", UUID(as_uuid=True), sa.ForeignKey("banner_sets.id"), nullable=True),
sa.Column("job_id", UUID(as_uuid=True), sa.ForeignKey("jobs.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index(
"ix_conversation_messages_conv_created",
"conversation_messages",
["conversation_id", "created_at"],
)
def downgrade() -> None:
op.drop_index("ix_conversation_messages_conv_created", table_name="conversation_messages")
op.drop_table("conversation_messages")
op.drop_index("ix_conversations_user_id", table_name="conversations")
op.drop_table("conversations")

View file

@ -0,0 +1,223 @@
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import get_current_user
from app.database import get_db
from app.models.brief import Brief
from app.models.conversation import Conversation, ConversationMessage
from app.models.job import Job, JobStatus, JobType
from app.models.user import User
from app.services.intent_router import classify_turn
router = APIRouter()
def _serialize_message(m: ConversationMessage) -> dict:
return {
"id": str(m.id),
"role": m.role,
"kind": m.kind,
"content": m.content,
"banner_set_id": str(m.banner_set_id) if m.banner_set_id else None,
"job_id": str(m.job_id) if m.job_id else None,
"created_at": m.created_at,
}
def _serialize_conversation(c: Conversation) -> dict:
return {
"id": str(c.id),
"brief_id": str(c.brief_id),
"title": c.title,
"created_at": c.created_at,
"updated_at": c.updated_at,
"messages": [_serialize_message(m) for m in c.messages],
}
class CreateConversationPayload(BaseModel):
first_message: str = ""
class SendMessagePayload(BaseModel):
text: str
@router.post("", status_code=201)
async def create_conversation(
payload: CreateConversationPayload,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.workers.tasks import queue
brief = Brief(user_id=current_user.id, text=payload.first_message or "")
db.add(brief)
await db.flush()
title = (payload.first_message or "New conversation")[:60]
conversation = Conversation(
user_id=current_user.id,
brief_id=brief.id,
title=title,
)
db.add(conversation)
await db.flush()
first_message_id = None
job_id = None
if payload.first_message.strip():
user_msg = ConversationMessage(
conversation_id=conversation.id,
role="user",
kind="text",
content=payload.first_message,
)
db.add(user_msg)
await db.flush()
first_message_id = str(user_msg.id)
job = Job(
type=JobType.CHAT_TURN,
status=JobStatus.PENDING,
payload={
"conversation_id": str(conversation.id),
"message_id": str(user_msg.id),
"message_text": payload.first_message,
},
)
db.add(job)
await db.commit()
await db.refresh(job)
queue.enqueue("app.workers.tasks.chat_turn", str(job.id), str(conversation.id), job_timeout=180)
job_id = str(job.id)
else:
await db.commit()
return {
"conversation_id": str(conversation.id),
"brief_id": str(brief.id),
"message_id": first_message_id,
"job_id": job_id,
}
@router.get("")
async def list_conversations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Conversation)
.where(Conversation.user_id == current_user.id)
.order_by(desc(Conversation.updated_at))
.limit(50)
)
convs = result.scalars().all()
return {
"conversations": [
{"id": str(c.id), "title": c.title, "created_at": c.created_at, "updated_at": c.updated_at}
for c in convs
]
}
@router.get("/{conversation_id}")
async def get_conversation(
conversation_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Conversation)
.where(Conversation.id == conversation_id, Conversation.user_id == current_user.id)
.options(selectinload(Conversation.messages))
)
conv = result.scalar_one_or_none()
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
return _serialize_conversation(conv)
@router.post("/{conversation_id}/messages", status_code=202)
async def send_message(
conversation_id: UUID,
payload: SendMessagePayload,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.workers.tasks import queue
result = await db.execute(
select(Conversation)
.where(Conversation.id == conversation_id, Conversation.user_id == current_user.id)
.options(selectinload(Conversation.messages))
)
conv = result.scalar_one_or_none()
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
brief_result = await db.execute(select(Brief).where(Brief.id == conv.brief_id))
brief = brief_result.scalar_one()
history = [
{"role": m.role, "content": m.content}
for m in conv.messages
if m.role in ("user", "assistant")
]
intent = await classify_turn(brief.text, history, payload.text)
user_msg = ConversationMessage(
conversation_id=conv.id,
role="user",
kind="text",
content=payload.text,
)
db.add(user_msg)
await db.flush()
if intent.intent == "clarify":
assistant_msg = ConversationMessage(
conversation_id=conv.id,
role="assistant",
kind="text",
content=intent.reply_text or "How can I help you with your banners?",
)
db.add(assistant_msg)
conv.updated_at = datetime.now(timezone.utc)
await db.commit()
return {
"message_id": str(user_msg.id),
"job_id": None,
"reply": intent.reply_text,
}
job = Job(
type=JobType.CHAT_TURN,
status=JobStatus.PENDING,
payload={
"conversation_id": str(conv.id),
"message_id": str(user_msg.id),
"message_text": payload.text,
},
)
db.add(job)
conv.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(job)
queue.enqueue("app.workers.tasks.chat_turn", str(job.id), str(conv.id), job_timeout=180)
return {
"message_id": str(user_msg.id),
"job_id": str(job.id),
"reply": None,
}

View file

@ -1,12 +1,13 @@
from fastapi import APIRouter
from app.api import health, auth, briefs, jobs, icons, dam, banner_variants, banner_sets, admin
from app.api import health, auth, briefs, jobs, icons, dam, banner_variants, banner_sets, admin, conversations
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(briefs.router, prefix="/briefs", tags=["briefs"])
api_router.include_router(conversations.router, prefix="/conversations", tags=["conversations"])
api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
api_router.include_router(icons.router, prefix="/icons", tags=["icons"])
api_router.include_router(dam.router, prefix="/dam", tags=["dam"])

View file

@ -6,8 +6,10 @@ from app.models.rag import RagChunk
from app.models.job import Job
from app.models.dam_cache import DamCache
from app.models.system_prompt import SystemPrompt
from app.models.conversation import Conversation, ConversationMessage
__all__ = [
"User", "Brief", "BannerSet", "BannerVariant",
"Icon", "RagChunk", "Job", "DamCache", "SystemPrompt",
"Conversation", "ConversationMessage",
]

View file

@ -0,0 +1,54 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Conversation(Base):
__tablename__ = "conversations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
brief_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("briefs.id"), nullable=False)
title: Mapped[str] = mapped_column(String(120), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
messages: Mapped[list["ConversationMessage"]] = relationship(
"ConversationMessage", back_populates="conversation", order_by="ConversationMessage.created_at"
)
class ConversationMessage(Base):
__tablename__ = "conversation_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # user | assistant
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="text") # text | generation | refinement
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
banner_set_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("banner_sets.id"), nullable=True
)
job_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("jobs.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages")
__table_args__ = (
Index("ix_conversation_messages_conv_created", "conversation_id", "created_at"),
)

View file

@ -18,6 +18,7 @@ class JobStatus(str, enum.Enum):
class JobType(str, enum.Enum):
GENERATE_COPY = "generate_copy"
CHAT_TURN = "chat_turn"
RENDER_PDF = "render_pdf"
INDEX_ICONS = "index_icons"
INGEST_RAG = "ingest_rag"

View file

@ -0,0 +1,97 @@
"""Classify a chat turn into one of three intents.
Intent: generate produce a fresh BannerSet via generate_copy
Intent: refine tweak existing variants via refine_variant_copy
Intent: clarify answer inline; no banner work
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from app.llm.openai_client import chat_structured
logger = logging.getLogger(__name__)
_INTENT_SCHEMA = {
"type": "object",
"properties": {
"intent": {
"type": "string",
"enum": ["generate", "refine", "clarify"],
},
"n_variants": {
"type": "integer",
"minimum": 1,
"maximum": 8,
},
"refined_brief": {
"type": "string",
},
"feedback": {
"type": "string",
},
"reply_text": {
"type": "string",
},
},
"required": ["intent", "n_variants", "refined_brief", "feedback", "reply_text"],
"additionalProperties": False,
}
_CLASSIFIER_SYSTEM = """You are a routing assistant for a Barclays banner-copy generation tool.
Given the conversation history and the user's latest message, output JSON with:
- intent: "generate" if the user wants new banner copy (first message, or "regenerate", "give me N variants", "start over");
"refine" if the user wants to tweak existing copy without full regeneration ("make titles more urgent", "change rate to 6%", "shorter", "warmer tone");
"clarify" if the user is asking a question, giving context, or the message is off-topic / administrative.
- n_variants: number of variants the user wants (default 4; parse from phrases like "give me 6 variants"); only relevant for "generate".
- refined_brief: for "generate", a single paragraph that incorporates ALL prior context + the new request, suitable as a standalone brief;
for "refine" or "clarify", leave as empty string.
- feedback: for "refine", a concise instruction to pass to the copy-refinement model (e.g. "Make titles more urgent and reduce body length");
otherwise empty string.
- reply_text: for "clarify", a brief helpful reply (13 sentences, UK English, Barclays professional tone);
for other intents, leave as empty string.
Always output valid JSON matching the schema. Never generate HTML, CSS, or code."""
@dataclass
class Intent:
intent: str # "generate" | "refine" | "clarify"
n_variants: int = 4
refined_brief: str = ""
feedback: str = ""
reply_text: str = ""
async def classify_turn(
brief_text: str,
history: list[dict],
latest_message: str,
) -> Intent:
"""Classify the latest user message given conversation history.
Args:
brief_text: The original brief text (for context).
history: List of {role, content} dicts for prior turns.
latest_message: The raw text the user just sent.
"""
messages = [
{"role": "system", "content": _CLASSIFIER_SYSTEM},
*history,
{"role": "user", "content": latest_message},
]
try:
raw = await chat_structured(messages, _INTENT_SCHEMA, temperature=0.1)
return Intent(
intent=raw.get("intent", "generate"),
n_variants=max(1, min(8, int(raw.get("n_variants") or 4))),
refined_brief=raw.get("refined_brief") or "",
feedback=raw.get("feedback") or "",
reply_text=raw.get("reply_text") or "",
)
except Exception:
logger.exception("Intent classification failed; defaulting to generate")
return Intent(intent="generate", refined_brief=latest_message)

View file

@ -25,23 +25,69 @@ def _run(coro):
return asyncio.run(coro)
async def _persist_banner_set(brief_id, copy_variants, db):
"""Create a BannerSet with paired Medium+Large variants. Returns the new BannerSet."""
import uuid as _uuid
from app.models.banner import BannerSet, BannerVariant
from app.services.icon_matcher import match_icon
from app.services.adobe_dam_client import get_dam_client
from app.config import get_settings as _get_settings
dam = get_dam_client(_get_settings())
banner_set = BannerSet(brief_id=brief_id)
db.add(banner_set)
await db.flush()
for cv in copy_variants:
pair_id = _uuid.uuid4()
icon = await match_icon(cv.icon_keyword, db)
icon_id = icon.id if icon else None
dam_asset = await dam.recommend_for_brief("")
dam_ref = dam_asset["id"] if dam_asset else None
dam_url = dam_asset["url"] if dam_asset else None
db.add(BannerVariant(
banner_set_id=banner_set.id,
aspect_ratio="Medium",
pair_id=pair_id,
theme=cv.theme,
short_title=cv.medium.short_title,
long_body=cv.medium.long_body,
cta=cv.medium.cta,
cta_secondary=None,
icon_id=icon_id,
dam_asset_ref=dam_ref,
dam_asset_url=dam_url,
))
db.add(BannerVariant(
banner_set_id=banner_set.id,
aspect_ratio="Large",
pair_id=pair_id,
theme=cv.theme,
short_title=cv.large.short_title,
long_body=cv.large.long_body,
cta=cv.large.cta,
cta_secondary=cv.large.cta_secondary or None,
icon_id=icon_id,
dam_asset_ref=dam_ref,
dam_asset_url=dam_url,
))
return banner_set
def generate_copy(job_id: str, brief_id: str) -> None:
"""RQ task: generate copy variants for a brief and persist them."""
_run(_generate_copy_async(job_id, brief_id))
async def _generate_copy_async(job_id: str, brief_id: str) -> None:
import uuid as _uuid
from uuid import UUID
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.job import Job, JobStatus
from app.models.brief import Brief
from app.models.banner import BannerSet, BannerVariant
from app.services.copy_generation import generate_copy as _gen
from app.services.icon_matcher import match_icon
from app.services.adobe_dam_client import get_dam_client
from app.config import get_settings as _get_settings
async with AsyncSessionLocal() as db:
job_result = await db.execute(select(Job).where(Job.id == UUID(job_id)))
@ -58,59 +104,14 @@ async def _generate_copy_async(job_id: str, brief_id: str) -> None:
aspect_ratios = payload.get("aspect_ratios", ["Medium", "Large"])
copy_variants = await _gen(brief.text, aspect_ratios, n_variants, db)
dam = get_dam_client(_get_settings())
banner_set = BannerSet(brief_id=brief.id)
db.add(banner_set)
await db.flush()
total_rows = 0
for cv in copy_variants:
pair_id = _uuid.uuid4()
# Pre-select icon using embedding similarity on the AI-suggested keyword
icon = await match_icon(cv.icon_keyword, db)
icon_id = icon.id if icon else None
# Pre-select a DAM image (shared between Medium and Large)
dam_asset = await dam.recommend_for_brief(brief.text)
dam_ref = dam_asset["id"] if dam_asset else None
dam_url = dam_asset["url"] if dam_asset else None
db.add(BannerVariant(
banner_set_id=banner_set.id,
aspect_ratio="Medium",
pair_id=pair_id,
theme=cv.theme,
short_title=cv.medium.short_title,
long_body=cv.medium.long_body,
cta=cv.medium.cta,
cta_secondary=None,
icon_id=icon_id,
dam_asset_ref=dam_ref,
dam_asset_url=dam_url,
))
db.add(BannerVariant(
banner_set_id=banner_set.id,
aspect_ratio="Large",
pair_id=pair_id,
theme=cv.theme,
short_title=cv.large.short_title,
long_body=cv.large.long_body,
cta=cv.large.cta,
cta_secondary=cv.large.cta_secondary or None,
icon_id=icon_id,
dam_asset_ref=dam_ref,
dam_asset_url=dam_url,
))
total_rows += 2
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
total_rows = len(banner_set.variants) if hasattr(banner_set, 'variants') else n_variants * 2
job.status = JobStatus.DONE
job.result = {"banner_set_id": str(banner_set.id), "variant_count": total_rows}
job.result = {"banner_set_id": str(banner_set.id), "variant_count": n_variants * 2}
job.finished_at = datetime.now(timezone.utc)
await db.commit()
logger.info("generate_copy done: job=%s banner_set=%s variants=%d", job_id, banner_set.id, total_rows)
logger.info("generate_copy done: job=%s banner_set=%s", job_id, banner_set.id)
except Exception as exc:
job.status = JobStatus.FAILED
@ -121,6 +122,159 @@ async def _generate_copy_async(job_id: str, brief_id: str) -> None:
raise
def chat_turn(job_id: str, conversation_id: str) -> None:
"""RQ task: process one chat turn (generate or refine intent)."""
_run(_chat_turn_async(job_id, conversation_id))
async def _chat_turn_async(job_id: str, conversation_id: str) -> None:
from uuid import UUID
from datetime import datetime, timezone as _tz
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal
from app.models.job import Job, JobStatus
from app.models.conversation import Conversation, ConversationMessage
from app.models.brief import Brief
from app.models.banner import BannerSet, BannerVariant
from app.services.copy_generation import generate_copy as _gen, refine_variant_copy
from app.services.intent_router import classify_turn
async with AsyncSessionLocal() as db:
job_result = await db.execute(select(Job).where(Job.id == UUID(job_id)))
job = job_result.scalar_one()
job.status = JobStatus.RUNNING
await db.commit()
try:
conv_result = await db.execute(
select(Conversation)
.where(Conversation.id == UUID(conversation_id))
.options(selectinload(Conversation.messages))
)
conversation = conv_result.scalar_one()
brief_result = await db.execute(select(Brief).where(Brief.id == conversation.brief_id))
brief = brief_result.scalar_one()
payload = job.payload or {}
latest_text = payload.get("message_text", "")
message_id = UUID(payload["message_id"])
history = [
{"role": m.role, "content": m.content}
for m in conversation.messages
if str(m.id) != payload["message_id"] and m.role in ("user", "assistant")
]
intent = await classify_turn(brief.text, history, latest_text)
if intent.intent == "generate":
effective_brief = intent.refined_brief or latest_text or brief.text
copy_variants = await _gen(effective_brief, ["Medium", "Large"], intent.n_variants, db)
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
await db.flush()
assistant_msg = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
kind="generation",
content=f"Generated {intent.n_variants} variants.",
banner_set_id=banner_set.id,
job_id=UUID(job_id),
)
db.add(assistant_msg)
elif intent.intent == "refine":
latest_set_result = await db.execute(
select(BannerSet)
.where(BannerSet.brief_id == brief.id)
.order_by(BannerSet.created_at.desc())
.limit(1)
)
latest_set = latest_set_result.scalar_one_or_none()
if latest_set:
variants_result = await db.execute(
select(BannerVariant).where(BannerVariant.banner_set_id == latest_set.id)
)
existing_variants = variants_result.scalars().all()
new_banner_set = BannerSet(brief_id=brief.id)
db.add(new_banner_set)
await db.flush()
feedback = intent.feedback or latest_text
for v in existing_variants:
from app.services.copy_generation import BannerCopy
current_copy = BannerCopy(
short_title=v.short_title,
long_body=v.long_body,
cta=v.cta,
cta_secondary=v.cta_secondary or "",
)
refined = await refine_variant_copy(v.aspect_ratio, current_copy, feedback, db)
db.add(BannerVariant(
banner_set_id=new_banner_set.id,
aspect_ratio=v.aspect_ratio,
pair_id=v.pair_id,
theme=v.theme,
short_title=refined.short_title,
long_body=refined.long_body,
cta=refined.cta,
cta_secondary=refined.cta_secondary or None,
icon_id=v.icon_id,
dam_asset_ref=v.dam_asset_ref,
dam_asset_url=v.dam_asset_url,
))
assistant_msg = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
kind="refinement",
content="Here's the refined copy based on your feedback.",
banner_set_id=new_banner_set.id,
job_id=UUID(job_id),
)
db.add(assistant_msg)
else:
copy_variants = await _gen(brief.text, ["Medium", "Large"], 4, db)
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
await db.flush()
assistant_msg = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
kind="generation",
content="No previous variants found — here's a fresh set.",
banner_set_id=banner_set.id,
job_id=UUID(job_id),
)
db.add(assistant_msg)
user_msg_result = await db.execute(
select(ConversationMessage).where(ConversationMessage.id == message_id)
)
user_msg = user_msg_result.scalar_one_or_none()
if user_msg:
user_msg.job_id = UUID(job_id)
conversation.updated_at = datetime.now(_tz.utc)
job.status = JobStatus.DONE
job.result = {"message_id": str(assistant_msg.id), "conversation_id": conversation_id}
job.finished_at = datetime.now(_tz.utc)
await db.commit()
logger.info("chat_turn done: job=%s conv=%s", job_id, conversation_id)
except Exception as exc:
job.status = JobStatus.FAILED
job.error = str(exc)
job.finished_at = datetime.now(_tz.utc)
await db.commit()
logger.exception("chat_turn failed: job=%s", job_id)
raise
def render_pdf(job_id: str, banner_set_id: str) -> None:
"""RQ task: render PDF contact sheet for a banner set."""
_run(_render_pdf_async(job_id, banner_set_id))

View file

@ -1,6 +1,8 @@
import { Routes, Route, Navigate } from "react-router-dom";
import Layout from "@/components/Layout";
import BriefEditor from "@/pages/BriefEditor";
import ChatBrief from "@/pages/ChatBrief";
import ConversationLanding from "@/pages/ConversationLanding";
import VariantsGrid from "@/pages/VariantsGrid";
import BannerEditor from "@/pages/BannerEditor";
import ExportPage from "@/pages/ExportPage";
@ -26,7 +28,9 @@ export default function App() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/brief" replace />} />
<Route index element={<Navigate to="/conversations/new" replace />} />
<Route path="/conversations/new" element={<ConversationLanding />} />
<Route path="/conversations/:conversationId" element={<ChatBrief />} />
<Route path="/brief" element={<BriefEditor />} />
<Route path="/brief/:briefId/variants" element={<VariantsGrid />} />
<Route path="/banner-sets/:bannerSetId/edit" element={<BannerEditor />} />

View file

@ -5,12 +5,14 @@ import { JourneyStepper, type StepperStep } from "@/components/ui/stepper";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ to: "/brief", label: "New Brief" },
{ to: "/conversations/new", label: "New Brief" },
{ to: "/history", label: "History" },
{ to: "/admin", label: "Admin", adminOnly: true },
];
const CREATION_ROUTES = [
"/conversations/new",
"/conversations/:conversationId",
"/brief",
"/brief/:briefId/variants",
"/banner-sets/:bannerSetId/edit",
@ -19,7 +21,7 @@ const CREATION_ROUTES = [
function useJourneySteps(): { steps: StepperStep[]; currentOrder: number } | null {
const location = useLocation();
const { briefId, bannerSetId, furthestStep, isFresh } = useJourneyStore();
const { conversationId, briefId, bannerSetId, furthestStep, isFresh } = useJourneyStore();
const isCreationFlow = CREATION_ROUTES.some((pattern) =>
matchPath(pattern, location.pathname)
@ -30,13 +32,19 @@ function useJourneySteps(): { steps: StepperStep[]; currentOrder: number } | nul
if (matchPath("/brief/:briefId/variants", location.pathname)) currentOrder = 2;
else if (matchPath("/banner-sets/:bannerSetId/edit", location.pathname)) currentOrder = 3;
else if (matchPath("/banner-sets/:bannerSetId/export", location.pathname)) currentOrder = 4;
// conversation routes stay at step 1 (prompt)
// Stale sessions (>2 h old) must not expose backward navigation — the stored
// bannerSetId may point to a completely different brief from a previous day.
const fresh = isFresh();
const defs = [
{ key: "prompt", label: "Prompt", order: 1, href: "/brief" },
{
key: "prompt",
label: "Prompt",
order: 1,
href: fresh && conversationId ? `/conversations/${conversationId}` : "/conversations/new",
},
{
key: "edit",
label: "Edit",

View file

@ -0,0 +1,308 @@
import { useEffect, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { apiClient } from "@/api/client";
import { pollJob } from "@/lib/jobPolling";
import { useJourneyStore } from "@/store/journey";
import BannerPreview from "@/components/BannerPreview";
import type { ConversationMessage } from "@/types/conversation";
import type { Variant } from "@/types/banner";
interface BannerSetCache {
[bannerSetId: string]: Variant[];
}
export default function ChatBrief() {
const { conversationId } = useParams<{ conversationId: string }>();
const navigate = useNavigate();
const { setConversation, setBrief, setBannerSet, markStageReached, setSelectedVariants } = useJourneyStore();
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [bannerSets, setBannerSets] = useState<BannerSetCache>({});
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [selectionByMessage, setSelectionByMessage] = useState<Record<string, Set<string>>>({});
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!conversationId) return;
setConversation(conversationId);
markStageReached("prompt");
loadConversation();
}, [conversationId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function loadConversation() {
const data = await apiClient.get<{ messages: ConversationMessage[]; brief_id: string }>(
`/api/conversations/${conversationId}`
);
setBrief(data.brief_id);
setMessages(data.messages);
for (const m of data.messages) {
if (m.banner_set_id && !bannerSets[m.banner_set_id]) {
loadBannerSet(m.banner_set_id);
}
}
}
async function loadBannerSet(bannerSetId: string) {
const data = await apiClient.get<{ variants: Variant[] }>(`/api/banner-sets/${bannerSetId}`);
setBannerSets((prev) => ({ ...prev, [bannerSetId]: data.variants }));
initSelection(bannerSetId, data.variants);
}
function initSelection(bannerSetId: string, variants: Variant[]) {
const msgId = messages.find((m) => m.banner_set_id === bannerSetId)?.id;
if (!msgId) return;
setSelectionByMessage((prev) => ({
...prev,
[msgId]: new Set(variants.map((v) => v.id)),
}));
}
function toggleVariant(msgId: string, variantId: string) {
setSelectionByMessage((prev) => {
const cur = new Set(prev[msgId] ?? []);
cur.has(variantId) ? cur.delete(variantId) : cur.add(variantId);
return { ...prev, [msgId]: cur };
});
}
function canProceed(msgId: string, bannerSetId: string) {
const variants = bannerSets[bannerSetId] ?? [];
const sel = selectionByMessage[msgId] ?? new Set();
return (
variants.filter((v) => v.aspect_ratio === "Medium").some((v) => sel.has(v.id)) &&
variants.filter((v) => v.aspect_ratio === "Large").some((v) => sel.has(v.id))
);
}
function openEditor(msgId: string, bannerSetId: string) {
const variants = bannerSets[bannerSetId] ?? [];
const sel = selectionByMessage[msgId] ?? new Set();
const ids = sel.size < variants.length ? Array.from(sel) : null;
setBannerSet(bannerSetId);
setSelectedVariants(ids);
markStageReached("banner-editor");
navigate(`/banner-sets/${bannerSetId}/edit`);
}
async function sendMessage(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || busy) return;
setInput("");
setBusy(true);
const optimisticUser: ConversationMessage = {
id: `optimistic-${Date.now()}`,
role: "user",
kind: "text",
content: text,
banner_set_id: null,
job_id: null,
created_at: new Date().toISOString(),
};
const thinking: ConversationMessage = {
id: `thinking-${Date.now()}`,
role: "assistant",
kind: "text",
content: "...",
banner_set_id: null,
job_id: null,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimisticUser, thinking]);
try {
const res = await apiClient.post<{ message_id: string; job_id: string | null; reply: string | null }>(
`/api/conversations/${conversationId}/messages`,
{ text }
);
if (res.reply) {
setMessages((prev) =>
prev.map((m) =>
m.id === thinking.id ? { ...thinking, id: res.message_id, content: res.reply! } : m
)
);
setBusy(false);
return;
}
if (res.job_id) {
await pollJob(res.job_id, {
intervalMs: 2000,
onProgress: () => {},
});
await loadConversation();
setMessages((prev) => prev.filter((m) => m.id !== thinking.id && !m.id.startsWith("optimistic-")));
}
} catch (err) {
setMessages((prev) =>
prev.map((m) =>
m.id === thinking.id
? { ...thinking, content: err instanceof Error ? err.message : "Something went wrong." }
: m
)
);
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col h-[calc(100vh-10rem)] max-w-4xl mx-auto">
<div className="mb-4">
<h1 className="text-2xl font-semibold text-barclays-dark">Banner brief</h1>
<p className="text-sm text-gray-500 mt-1">
Describe your campaign, then refine or ask for more options.
</p>
</div>
{/* Message list */}
<div className="flex-1 overflow-y-auto space-y-4 pb-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 text-sm mt-16">
Start by describing your campaign below.
</div>
)}
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[90%] rounded-2xl px-4 py-3 text-sm ${
m.role === "user"
? "bg-barclays-blue text-white rounded-tr-sm"
: "bg-white border border-gray-200 text-barclays-dark rounded-tl-sm shadow-sm"
}`}
>
{(m.kind === "generation" || m.kind === "refinement") && m.banner_set_id ? (
<BannerSetMessage
msgId={m.id}
bannerSetId={m.banner_set_id}
variants={bannerSets[m.banner_set_id]}
selection={selectionByMessage[m.id] ?? new Set()}
canOpen={canProceed(m.id, m.banner_set_id)}
onToggle={(vid) => toggleVariant(m.id, vid)}
onOpen={() => openEditor(m.id, m.banner_set_id!)}
/>
) : m.id.startsWith("thinking-") || m.content === "..." ? (
<span className="flex items-center gap-2">
<span className="inline-block w-3 h-3 border-2 border-barclays-blue/30 border-t-barclays-blue rounded-full animate-spin" />
Generating
</span>
) : (
<span className="whitespace-pre-wrap">{m.content}</span>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<form onSubmit={sendMessage} className="border-t pt-4 flex gap-3">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(e as unknown as React.FormEvent); }
}}
disabled={busy}
rows={2}
placeholder={
messages.length === 0
? "e.g. Promote our Everyday Saver to existing customers. Give me 4 variants."
: "Ask for changes, more options, or a different tone…"
}
className="flex-1 border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-barclays-blue resize-none disabled:opacity-50"
/>
<button
type="submit"
disabled={busy || !input.trim()}
className="px-5 py-3 bg-barclays-dark text-white rounded-xl text-sm font-medium hover:bg-barclays-mid transition-colors disabled:opacity-40 self-end"
>
Send
</button>
</form>
</div>
);
}
interface BannerSetMessageProps {
msgId: string;
bannerSetId: string;
variants: Variant[] | undefined;
selection: Set<string>;
canOpen: boolean;
onToggle: (variantId: string) => void;
onOpen: () => void;
}
function BannerSetMessage({ variants, selection, canOpen, onToggle, onOpen }: BannerSetMessageProps) {
if (!variants) {
return (
<span className="flex items-center gap-2 text-sm text-gray-400">
<span className="inline-block w-3 h-3 border-2 border-barclays-blue/30 border-t-barclays-blue rounded-full animate-spin" />
Loading variants
</span>
);
}
const byRatio: Record<string, Variant[]> = {};
for (const v of variants) (byRatio[v.aspect_ratio] ??= []).push(v);
const orderedRatios = ["Medium", "Large"].filter((r) => byRatio[r]);
return (
<div className="space-y-4 min-w-[520px]">
<p className="text-xs text-gray-500">{variants.length} variants tick at least one Medium and one Large, then open the editor.</p>
{orderedRatios.map((ratio) => (
<div key={ratio}>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">{ratio}</h3>
<div className="grid grid-cols-2 gap-3">
{byRatio[ratio].map((v) => (
<div
key={v.id}
onClick={() => onToggle(v.id)}
className={`cursor-pointer rounded-xl border overflow-hidden transition-all ${
selection.has(v.id) ? "border-barclays-blue ring-1 ring-barclays-blue/30" : "border-gray-200 opacity-60"
}`}
>
<div className="bg-gray-50 p-2 flex justify-center">
<BannerPreview variant={v} scale={ratio === "Large" ? 0.8 : 1} showClose={false} />
</div>
<div className="p-2 flex items-center gap-2">
<input
type="checkbox"
checked={selection.has(v.id)}
onChange={() => onToggle(v.id)}
onClick={(e) => e.stopPropagation()}
className="accent-barclays-blue shrink-0"
/>
<p className="text-xs text-barclays-dark font-medium truncate">{v.short_title}</p>
</div>
</div>
))}
</div>
</div>
))}
<div className="pt-1 flex items-center gap-3">
<button
onClick={onOpen}
disabled={!canOpen}
className="text-sm px-4 py-2 bg-barclays-blue text-white rounded-lg hover:bg-barclays-mid transition-colors disabled:opacity-40 font-medium"
>
Open banner editor ({selection.size})
</button>
{!canOpen && (
<span className="text-xs text-amber-600">Select at least one Medium and one Large.</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { apiClient } from "@/api/client";
import { useJourneyStore } from "@/store/journey";
export default function ConversationLanding() {
const navigate = useNavigate();
const { resetJourney } = useJourneyStore();
useEffect(() => {
resetJourney();
apiClient
.post<{ conversation_id: string }>("/api/conversations", {})
.then((d) => navigate(`/conversations/${d.conversation_id}`, { replace: true }))
.catch(() => navigate("/brief", { replace: true }));
}, []);
return (
<div className="text-sm text-gray-400 animate-pulse text-center mt-24">Starting conversation</div>
);
}

View file

@ -113,6 +113,14 @@ export default function VariantsGrid() {
const hasLarge = (byRatio.Large ?? []).some((v) => checkedIds.has(v.id));
const canProceed = hasMedium && hasLarge;
const editingVariant = editingId ? variants.find((v) => v.id === editingId) : null;
const editLim = editingVariant ? (LIMITS[editingVariant.aspect_ratio] ?? DEFAULT_LIMITS) : DEFAULT_LIMITS;
const overLimit =
(edits.short_title ?? "").length > editLim.title ||
(edits.long_body ?? "").length > editLim.body ||
(edits.cta ?? "").length > CTA_MAX ||
(edits.cta_secondary ?? "").length > CTA_MAX;
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
@ -263,13 +271,17 @@ export default function VariantsGrid() {
</button>
</div>
<div className="flex gap-2 pt-2">
<div className="flex items-center gap-2 pt-2">
<button
onClick={() => saveEdit(variant.id)}
className="text-xs px-3 py-1.5 bg-barclays-dark text-white rounded hover:bg-barclays-mid transition-colors"
disabled={overLimit}
className="text-xs px-3 py-1.5 bg-barclays-dark text-white rounded hover:bg-barclays-mid transition-colors disabled:opacity-40"
>
Save
</button>
{overLimit && (
<span className="text-xs text-red-500">One or more fields exceed the character limit.</span>
)}
<button
onClick={() => { setEditingId(null); setEdits({}); setRefineNote(""); }}
className="text-xs px-3 py-1.5 border rounded text-gray-600 hover:bg-gray-50 transition-colors"

View file

@ -15,11 +15,13 @@ const STAGE_ORDER: Record<JourneyStage, 1 | 2 | 3 | 4> = {
const JOURNEY_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
interface JourneyState {
conversationId: string | null;
briefId: string | null;
bannerSetId: string | null;
furthestStep: 1 | 2 | 3 | 4;
selectedVariantIds: string[] | null;
lastUpdatedAt: number | null;
setConversation: (conversationId: string) => void;
setBrief: (briefId: string) => void;
setBannerSet: (bannerSetId: string) => void;
markStageReached: (stage: JourneyStage) => void;
@ -32,11 +34,13 @@ interface JourneyState {
export const useJourneyStore = create<JourneyState>()(
persist(
(set, get) => ({
conversationId: null,
briefId: null,
bannerSetId: null,
furthestStep: 1,
selectedVariantIds: null,
lastUpdatedAt: null,
setConversation: (conversationId) => set({ conversationId, lastUpdatedAt: Date.now() }),
setBrief: (briefId) => set({ briefId, lastUpdatedAt: Date.now() }),
setBannerSet: (bannerSetId) => set({ bannerSetId, lastUpdatedAt: Date.now() }),
markStageReached: (stage) => {
@ -45,6 +49,7 @@ export const useJourneyStore = create<JourneyState>()(
},
setSelectedVariants: (ids) => set({ selectedVariantIds: ids, lastUpdatedAt: Date.now() }),
resetJourney: () => set({
conversationId: null,
briefId: null,
bannerSetId: null,
furthestStep: 1,

View file

@ -0,0 +1,18 @@
export interface ConversationMessage {
id: string;
role: "user" | "assistant";
kind: "text" | "generation" | "refinement";
content: string;
banner_set_id: string | null;
job_id: string | null;
created_at: string;
}
export interface Conversation {
id: string;
brief_id: string;
title: string;
created_at: string;
updated_at: string;
messages: ConversationMessage[];
}