Skip to content

Commit c3814ad

Browse files
Merge remote-tracking branch 'origin/dev' into psl-chathistorythreadname
2 parents 5bb692a + e6ce244 commit c3814ad

File tree

14 files changed

+8988
-4
lines changed

14 files changed

+8988
-4
lines changed

content-gen/src/app/frontend/src/App.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ function App() {
118118
setAwaitingClarification(false);
119119
setConfirmedBrief(data.brief || null);
120120

121+
// Restore availableProducts so product/color name detection works
122+
// when regenerating images in a restored conversation
123+
if (data.brief) {
124+
try {
125+
const productsResponse = await fetch('/api/products');
126+
if (productsResponse.ok) {
127+
const productsData = await productsResponse.json();
128+
setAvailableProducts(productsData.products || []);
129+
}
130+
} catch (err) {
131+
console.error('Error loading products for restored conversation:', err);
132+
}
133+
}
134+
121135
if (data.generated_content) {
122136
const gc = data.generated_content;
123137
let textContent = gc.text_content;
@@ -325,13 +339,20 @@ function App() {
325339
let responseData: GeneratedContent | null = null;
326340
let messageContent = '';
327341

342+
// Detect if the user's prompt mentions a different product/color name
343+
// BEFORE the API call so the correct product is sent and persisted
344+
const mentionedProduct = availableProducts.find(p =>
345+
content.toLowerCase().includes(p.product_name.toLowerCase())
346+
);
347+
const productsForRequest = mentionedProduct ? [mentionedProduct] : selectedProducts;
348+
328349
// Get previous prompt from image_content if available
329350
const previousPrompt = generatedContent.image_content?.prompt_used;
330351

331352
for await (const response of streamRegenerateImage(
332353
content,
333354
confirmedBrief,
334-
selectedProducts,
355+
productsForRequest,
335356
previousPrompt,
336357
conversationId,
337358
userId,
@@ -345,8 +366,21 @@ function App() {
345366

346367
// Update generatedContent with new image
347368
if (parsedContent.image_url || parsedContent.image_base64) {
369+
// Replace old color/product name in text_content when switching products
370+
const oldName = selectedProducts[0]?.product_name;
371+
const newName = mentionedProduct?.product_name;
372+
const nameRegex = oldName
373+
? new RegExp(oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
374+
: undefined;
375+
const swapName = (s?: string) => {
376+
if (!s || !oldName || !newName || oldName === newName || !nameRegex) return s;
377+
return s.replace(nameRegex, () => newName);
378+
};
379+
const tc = generatedContent.text_content;
380+
348381
responseData = {
349382
...generatedContent,
383+
text_content: mentionedProduct ? { ...tc, headline: swapName(tc?.headline), body: swapName(tc?.body), tagline: swapName(tc?.tagline), cta_text: swapName(tc?.cta_text) } : tc,
350384
image_content: {
351385
...generatedContent.image_content,
352386
image_url: parsedContent.image_url || generatedContent.image_content?.image_url,
@@ -356,6 +390,11 @@ function App() {
356390
};
357391
setGeneratedContent(responseData);
358392

393+
// Update the selected product/color name now that the new image is ready
394+
if (mentionedProduct) {
395+
setSelectedProducts([mentionedProduct]);
396+
}
397+
359398
// Update the confirmed brief to include the modification
360399
// This ensures subsequent "Regenerate" clicks use the updated visual guidelines
361400
const updatedBrief = {
@@ -552,7 +591,7 @@ function App() {
552591
// Trigger refresh of chat history after message is sent
553592
setHistoryRefreshTrigger(prev => prev + 1);
554593
}
555-
}, [conversationId, userId, confirmedBrief, pendingBrief, selectedProducts, generatedContent]);
594+
}, [conversationId, userId, confirmedBrief, pendingBrief, selectedProducts, generatedContent, availableProducts]);
556595

557596
const handleBriefConfirm = useCallback(async () => {
558597
if (!pendingBrief) return;

content-gen/src/app/frontend/src/components/ChatHistory.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,20 @@ function ConversationItem({
417417
const handleRenameConfirm = async () => {
418418
const trimmedValue = renameValue.trim();
419419

420+
// Validate before API call
421+
if (trimmedValue.length < 5) {
422+
setRenameError('Conversation name must be at least 5 characters');
423+
return;
424+
}
425+
if (trimmedValue.length > 50) {
426+
setRenameError('Conversation name cannot exceed 50 characters');
427+
return;
428+
}
429+
if (!/[a-zA-Z0-9]/.test(trimmedValue)) {
430+
setRenameError('Conversation name must contain at least one letter or number');
431+
return;
432+
}
433+
420434
if (trimmedValue === conversation.title) {
421435
setIsRenameDialogOpen(false);
422436
setRenameError('');
@@ -536,11 +550,18 @@ function ConversationItem({
536550
<Input
537551
ref={renameInputRef}
538552
value={renameValue}
553+
maxLength={50}
539554
onChange={(e) => {
540555
const newValue = e.target.value;
541556
setRenameValue(newValue);
542557
if (newValue.trim() === '') {
543558
setRenameError('Conversation name cannot be empty or contain only spaces');
559+
} else if (newValue.trim().length < 5) {
560+
setRenameError('Conversation name must be at least 5 characters');
561+
} else if (!/[a-zA-Z0-9]/.test(newValue)) {
562+
setRenameError('Conversation name must contain at least one letter or number');
563+
} else if (newValue.length > 50) {
564+
setRenameError('Conversation name cannot exceed 50 characters');
544565
} else {
545566
setRenameError('');
546567
}
@@ -555,6 +576,16 @@ function ConversationItem({
555576
placeholder="Enter conversation name"
556577
style={{ width: '100%' }}
557578
/>
579+
<Text
580+
size={200}
581+
style={{
582+
color: tokens.colorNeutralForeground3,
583+
marginTop: '4px',
584+
display: 'block'
585+
}}
586+
>
587+
Maximum 50 characters ({renameValue.length}/50)
588+
</Text>
558589
{renameError && (
559590
<Text
560591
size={200}
@@ -579,7 +610,7 @@ function ConversationItem({
579610
<Button
580611
appearance="primary"
581612
onClick={handleRenameConfirm}
582-
disabled={!renameValue.trim()}
613+
disabled={renameValue.trim().length < 5 || !/[a-zA-Z0-9]/.test(renameValue) || renameValue.length > 50}
583614
>
584615
Rename
585616
</Button>

content-gen/src/backend/app.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import json
1010
import logging
1111
import os
12+
import re
1213
import uuid
1314
from datetime import datetime, timezone
1415
from typing import Dict, Any
@@ -995,7 +996,7 @@ async def generate():
995996
except Exception as e:
996997
logger.warning(f"Failed to save regenerated image to blob: {e}")
997998

998-
# Save assistant response
999+
# Save assistant response and update persisted generated_content
9991000
try:
10001001
cosmos_service = await get_cosmos_service()
10011002
await cosmos_service.add_message_to_conversation(
@@ -1008,6 +1009,47 @@ async def generate():
10081009
"timestamp": datetime.now(timezone.utc).isoformat()
10091010
}
10101011
)
1012+
1013+
# Persist the regenerated image and updated products to generated_content
1014+
# so the latest image and color/product name are restored on conversation reload
1015+
new_image_url = response.get("image_url")
1016+
new_image_prompt = response.get("image_prompt")
1017+
new_image_revised_prompt = response.get("image_revised_prompt")
1018+
1019+
existing_conversation = await cosmos_service.get_conversation(conversation_id, user_id)
1020+
raw_content = (existing_conversation or {}).get("generated_content")
1021+
existing_content = raw_content if isinstance(raw_content, dict) else {}
1022+
old_image_url = existing_content.get("image_url")
1023+
1024+
# Replace old color/product name in text_content when product changes
1025+
old_products = existing_content.get("selected_products", [])
1026+
old_name = old_products[0].get("product_name", "") if old_products else ""
1027+
new_name = products_data[0].get("product_name", "") if products_data else ""
1028+
existing_text = existing_content.get("text_content")
1029+
if existing_text and old_name and new_name and old_name != new_name:
1030+
pat = re.compile(re.escape(old_name), re.IGNORECASE)
1031+
if isinstance(existing_text, dict):
1032+
existing_text = {
1033+
k: pat.sub(lambda _m: new_name, v) if isinstance(v, str) else v
1034+
for k, v in existing_text.items()
1035+
}
1036+
elif isinstance(existing_text, str):
1037+
existing_text = pat.sub(lambda _m: new_name, existing_text)
1038+
1039+
updated_content = {
1040+
**existing_content,
1041+
"image_url": new_image_url if new_image_url else old_image_url,
1042+
"image_prompt": new_image_prompt if new_image_prompt else existing_content.get("image_prompt"),
1043+
"image_revised_prompt": new_image_revised_prompt if new_image_revised_prompt else existing_content.get("image_revised_prompt"),
1044+
"selected_products": products_data if products_data else existing_content.get("selected_products", []),
1045+
**(({"text_content": existing_text} if existing_text is not None else {})),
1046+
}
1047+
1048+
await cosmos_service.save_generated_content(
1049+
conversation_id=conversation_id,
1050+
user_id=user_id,
1051+
generated_content=updated_content
1052+
)
10111053
except Exception as e:
10121054
logger.warning(f"Failed to save regeneration response to CosmosDB: {e}")
10131055

content-gen/src/backend/requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
pytest>=8.0.0
77
pytest-asyncio>=0.23.0
88
pytest-cov>=5.0.0
9+
pytest-mock>=3.14.0
910

1011
# Code Quality
1112
black>=24.0.0

content-gen/src/pytest.ini

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[pytest]
2+
# Pytest configuration for backend tests
3+
4+
# Test discovery patterns
5+
python_files = test_*.py
6+
python_classes = Test*
7+
python_functions = test_*
8+
9+
# Asyncio configuration
10+
asyncio_mode = auto
11+
12+
# Output configuration
13+
addopts =
14+
-v
15+
--strict-markers
16+
--tb=short
17+
--cov=backend
18+
--cov-report=term-missing
19+
--cov-report=html:coverage_html
20+
--cov-report=xml:coverage.xml
21+
--cov-fail-under=20
22+
23+
# Filter warnings
24+
filterwarnings =
25+
ignore::DeprecationWarning
26+
ignore::PendingDeprecationWarning
27+
ignore:Unclosed client session:ResourceWarning
28+
ignore:Unclosed connector:ResourceWarning
29+
30+
# Test paths
31+
testpaths = tests
32+
33+
# Coverage configuration
34+
[coverage:run]
35+
source = backend
36+
omit =
37+
tests/*
38+
*/tests/*
39+
*/test_*
40+
*/__pycache__/*
41+
*/site-packages/*
42+
conftest.py
43+
*/hypercorn.conf.py
44+
*/ApiApp.Dockerfile
45+
*/WebApp.Dockerfile
46+
47+
[coverage:report]
48+
exclude_lines =
49+
pragma: no cover
50+
def __repr__
51+
raise AssertionError
52+
raise NotImplementedError
53+
if __name__ == "__main__":
54+
if TYPE_CHECKING:
55+
@abstract

0 commit comments

Comments
 (0)