From 7e82a535a9ad616ce3237cdfd9dd9dfd531412f6 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Tue, 28 Apr 2026 21:53:17 +0100 Subject: [PATCH] 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 --- .../versions/0005_add_conversations.py | 51 +++ backend/app/api/conversations.py | 223 +++++++++++++ backend/app/api/router.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/conversation.py | 54 +++ backend/app/models/job.py | 1 + backend/app/services/intent_router.py | 97 ++++++ backend/app/workers/tasks.py | 262 ++++++++++++--- frontend/src/App.tsx | 6 +- frontend/src/components/Layout.tsx | 14 +- frontend/src/pages/ChatBrief.tsx | 308 ++++++++++++++++++ frontend/src/pages/ConversationLanding.tsx | 21 ++ frontend/src/pages/VariantsGrid.tsx | 16 +- frontend/src/store/journey.ts | 5 + frontend/src/types/conversation.ts | 18 + 15 files changed, 1020 insertions(+), 61 deletions(-) create mode 100644 backend/alembic/versions/0005_add_conversations.py create mode 100644 backend/app/api/conversations.py create mode 100644 backend/app/models/conversation.py create mode 100644 backend/app/services/intent_router.py create mode 100644 frontend/src/pages/ChatBrief.tsx create mode 100644 frontend/src/pages/ConversationLanding.tsx create mode 100644 frontend/src/types/conversation.ts diff --git a/backend/alembic/versions/0005_add_conversations.py b/backend/alembic/versions/0005_add_conversations.py new file mode 100644 index 0000000..17fc6b9 --- /dev/null +++ b/backend/alembic/versions/0005_add_conversations.py @@ -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") diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py new file mode 100644 index 0000000..fbead2c --- /dev/null +++ b/backend/app/api/conversations.py @@ -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, + } diff --git a/backend/app/api/router.py b/backend/app/api/router.py index f3b03af..885c6f9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8163c7a..c12d32c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py new file mode 100644 index 0000000..b2581b9 --- /dev/null +++ b/backend/app/models/conversation.py @@ -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"), + ) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index e2ff3b9..8f7370d 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -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" diff --git a/backend/app/services/intent_router.py b/backend/app/services/intent_router.py new file mode 100644 index 0000000..e8b81ce --- /dev/null +++ b/backend/app/services/intent_router.py @@ -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 (1–3 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) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index cac4a2f..06f1737 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -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)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f8dac0..ca7ed13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } > - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2e96db1..5bcbabd 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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", diff --git a/frontend/src/pages/ChatBrief.tsx b/frontend/src/pages/ChatBrief.tsx new file mode 100644 index 0000000..9089862 --- /dev/null +++ b/frontend/src/pages/ChatBrief.tsx @@ -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([]); + const [bannerSets, setBannerSets] = useState({}); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [selectionByMessage, setSelectionByMessage] = useState>>({}); + const bottomRef = useRef(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 ( +
+
+

Banner brief

+

+ Describe your campaign, then refine or ask for more options. +

+
+ + {/* Message list */} +
+ {messages.length === 0 && ( +
+ Start by describing your campaign below. +
+ )} + + {messages.map((m) => ( +
+
+ {(m.kind === "generation" || m.kind === "refinement") && m.banner_set_id ? ( + toggleVariant(m.id, vid)} + onOpen={() => openEditor(m.id, m.banner_set_id!)} + /> + ) : m.id.startsWith("thinking-") || m.content === "..." ? ( + + + Generating… + + ) : ( + {m.content} + )} +
+
+ ))} +
+
+ + {/* Input */} +
+