feat(feedback): Add content improvement feedback system

Frontend (frontend/app.js):

- Add textarea for improvement feedback

- Add submit button with loading state

- Handle API response and display improved content

Backend (backend/copywriter.py):

- Add improve_copy() method using Cohere API

- Integrate retry mechanism for API calls

Backend (backend/main.py):

- Add /improve-content POST endpoint

- Implement error handling and return improved content with metadata

Testing:

- Verified feedback submission flow

- Confirmed improved content generation

- Tested error scenarios and loading states
This commit is contained in:
Michael Ikehi
2025-04-21 17:32:33 +01:00
parent 6d07556b85
commit c1a894ad50
11 changed files with 1001 additions and 1471 deletions
+13 -9
View File
@@ -182,17 +182,21 @@ class BrandStyleManager:
raise raise
def format_prompt_with_brand_style(self, user_prompt: str, content_type: Optional[str] = None) -> str: def format_prompt_with_brand_style(self, user_prompt: str, content_type: Optional[str] = None) -> str:
"""Format user prompt to match the established writing style.""" """Format user prompt to match the distinctive communication style."""
style_instructions = [ style_instructions = [
"Follow these writing style guidelines:", "Follow these distinctive communication style guidelines:",
"- Use direct commands that empower the reader", "- Use empowering, assertive language that inspires action",
"- Address the reader directly using 'you' and 'your'", "- Address the reader directly using 'you' and 'your' with conviction",
"- Create rhythmic, repetitive patterns in key messages", "- Create rhythmic, repetitive patterns in key messages for emphasis",
"- Maintain a clear, confident, and authoritative tone", "- Maintain a clear, confident, and conversational teaching tone",
"- Use simple, practical language without jargon", "- Use simple, practical language that communicates profound ideas",
"- Acknowledge challenges while focusing on solutions", "- Use embedded commands (e.g., 'Decide now to change your thinking')",
"- Include empowering phrases that emphasize reader's control and choice" "- Include cause-effect statements (e.g., 'Because you understand this, you will now take action')",
"- Speak with conviction and clarity rather than hesitation",
"- Replace tentative phrases with confident declarations",
"- Use a motivational coach-like clarity in all communications",
"- IMPORTANT: Do not mention any specific person's name in the content"
] ]
# Content type specific formatting # Content type specific formatting
+13 -10
View File
@@ -52,12 +52,12 @@ CONTENT_TYPES = [
"newsletter" "newsletter"
] ]
# Tone options - simplified to match the core style # Tone options - specifically matching Adriana James' communication style
TONE_OPTIONS = [ TONE_OPTIONS = [
"direct",
"empowering", "empowering",
"confident", "assertive",
"practical" "inspirational",
"direct"
] ]
# Content length options # Content length options
@@ -67,19 +67,22 @@ LENGTH_OPTIONS = [
"long", # > 300 words "long", # > 300 words
] ]
# Default brand style guidelines # Default brand style guidelines - fixed to match Adriana James' distinct communication style
DEFAULT_BRAND_STYLE = { DEFAULT_BRAND_STYLE = {
"tone": ["direct", "empowering", "confident", "practical"], "tone": ["empowering", "assertive", "inspirational", "direct"],
"voice_characteristics": ["clear", "authoritative", "steady", "rhythmic"], "voice_characteristics": ["clear", "confident", "conversational", "teaching"],
"writing_patterns": ["direct commands", "personal pronouns", "repetitive rhythms"], "writing_patterns": ["direct commands", "personal pronouns", "repetitive rhythms", "embedded commands", "cause-effect statements"],
"taboo_words": ["cheap", "discount", "bargain", "failure", "impossible", "difficult"], "taboo_words": ["cheap", "discount", "bargain", "failure", "impossible", "difficult", "might", "try", "consider"],
"preferred_terms": { "preferred_terms": {
"problems": "challenges", "problems": "challenges",
"try": "take action", "try": "take action",
"difficult": "ready for growth", "difficult": "ready for growth",
"failure": "learning opportunity", "failure": "learning opportunity",
"hope": "know", "hope": "know",
"maybe": "will" "maybe": "will",
"might help you": "you can do this",
"consider doing this": "decide now to change your thinking",
"this could work": "this works because"
} }
} }
+62 -7
View File
@@ -44,9 +44,15 @@ class Copywriter:
# Step 2: Find similar content for reference (if enabled) # Step 2: Find similar content for reference (if enabled)
reference_content = [] reference_content = []
if reference_similar_content: if reference_similar_content:
logger.info(f"Searching for similar content to reference for prompt: {prompt[:50]}...")
search_results = await vector_store.search(prompt, top_k=3) search_results = await vector_store.search(prompt, top_k=3)
if search_results: if search_results:
reference_content = [result['text'] for result in search_results] reference_content = [result['text'] for result in search_results]
logger.info(f"Found {len(reference_content)} similar content items to reference")
for i, content in enumerate(reference_content):
logger.debug(f"Reference content {i+1}: {content[:100]}...")
else:
logger.warning("No similar content found in vector store for reference")
# Step 3: Add length and CTA instructions if needed # Step 3: Add length and CTA instructions if needed
if length: if length:
@@ -62,7 +68,10 @@ class Copywriter:
# Step 5: Generate content using the LLM # Step 5: Generate content using the LLM
generated_content = await self._call_llm_api(branded_prompt, max_tokens) generated_content = await self._call_llm_api(branded_prompt, max_tokens)
# Step 6: Check content alignment with brand style # Step 6: Post-process to remove any mentions of Adriana James
generated_content = self._remove_name_mentions(generated_content)
# Step 7: Check content alignment with brand style
alignment_check = brand_style_manager.check_content_alignment(generated_content) alignment_check = brand_style_manager.check_content_alignment(generated_content)
# Step 7: Generate alternative headline suggestions # Step 7: Generate alternative headline suggestions
@@ -104,10 +113,9 @@ class Copywriter:
max_tokens: Maximum tokens for the generated response max_tokens: Maximum tokens for the generated response
Returns: Returns:
Generated content as a string Generated content as a string with preserved formatting
""" """
try: try:
# Use Cohere's generate API with the API key from config
cohere_api_key = config.COHERE_API_KEY cohere_api_key = config.COHERE_API_KEY
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@@ -118,19 +126,30 @@ class Copywriter:
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
json={ json={
"model": "command", # Cohere's generation model "model": "command",
"prompt": prompt, "prompt": f"{prompt}\n\nNote: Please preserve formatting with proper paragraphs, line breaks, and bullet points where appropriate.",
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": 0.7, "temperature": 0.7,
"k": 0, "k": 0,
"p": 0.75 "p": 0.75,
"return_likelihoods": "NONE"
}, },
timeout=30.0 timeout=30.0
) )
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
return result["generations"][0]["text"].strip() generated_text = result["generations"][0]["text"].strip()
# Preserve paragraph breaks and formatting
formatted_text = (
generated_text
.replace("\n\n", "<paragraph-break>") # Preserve paragraph breaks
.replace("\n- ", "\n") # Convert hyphens to bullets
.replace("<paragraph-break>", "\n\n") # Restore paragraph breaks
)
return formatted_text
else: else:
logger.error(f"Cohere API error: {response.status_code}, {response.text}") logger.error(f"Cohere API error: {response.status_code}, {response.text}")
raise Exception(f"Cohere API error: {response.status_code}") raise Exception(f"Cohere API error: {response.status_code}")
@@ -156,6 +175,7 @@ class Copywriter:
Generate 3 alternative marketing headlines for the following content. Generate 3 alternative marketing headlines for the following content.
Make headlines compelling, concise, and aligned with the content's message. Make headlines compelling, concise, and aligned with the content's message.
Each headline should be unique and capture attention. Each headline should be unique and capture attention.
IMPORTANT: Do not mention any specific person's name in the headlines.
ORIGINAL PROMPT: ORIGINAL PROMPT:
{original_prompt} {original_prompt}
@@ -179,6 +199,9 @@ class Copywriter:
if headline.strip() and not headline.lower().startswith(('headline', 'title', '-', '*', '')) if headline.strip() and not headline.lower().startswith(('headline', 'title', '-', '*', ''))
] ]
# Remove any mentions of Adriana James from headlines
headlines = [self._remove_name_mentions(headline) for headline in headlines]
# Ensure we have exactly 3 headlines # Ensure we have exactly 3 headlines
if len(headlines) > 3: if len(headlines) > 3:
headlines = headlines[:3] headlines = headlines[:3]
@@ -208,6 +231,7 @@ class Copywriter:
# Format prompt for improvement # Format prompt for improvement
improve_prompt = f""" improve_prompt = f"""
Please improve the following marketing content based on the feedback provided: Please improve the following marketing content based on the feedback provided:
IMPORTANT: Do not mention any specific person's name in the content.
ORIGINAL CONTENT: ORIGINAL CONTENT:
{content} {content}
@@ -221,6 +245,9 @@ class Copywriter:
# Call LLM to improve content # Call LLM to improve content
improved_content = await self._call_llm_api(improve_prompt, max_tokens=1200) improved_content = await self._call_llm_api(improve_prompt, max_tokens=1200)
# Remove any mentions of Adriana James from improved content
improved_content = self._remove_name_mentions(improved_content)
logger.info(f"Improved content based on feedback") logger.info(f"Improved content based on feedback")
return improved_content return improved_content
@@ -277,5 +304,33 @@ class Copywriter:
logger.error(f"Error analyzing content: {str(e)}") logger.error(f"Error analyzing content: {str(e)}")
raise raise
def _remove_name_mentions(self, content: str) -> str:
"""
Remove any mentions of specific names from the generated content.
Args:
content: The generated content to process
Returns:
Content with name mentions removed
"""
try:
# Remove any mentions of "Adriana James" (case insensitive)
import re
pattern = re.compile(r'\bAdriana\s+James\b', re.IGNORECASE)
content = pattern.sub('', content)
# Clean up any double spaces that might result from the removal
content = re.sub(r'\s+', ' ', content)
# Clean up any lines that might now be empty
content = '\n'.join([line for line in content.split('\n') if line.strip()])
logger.info("Removed any name mentions from generated content")
return content
except Exception as e:
logger.error(f"Error removing name mentions: {str(e)}")
return content
# Create a singleton instance # Create a singleton instance
copywriter = Copywriter() copywriter = Copywriter()
+73
View File
@@ -34,6 +34,11 @@ class VectorStore:
self._load_or_create_index() self._load_or_create_index()
logger.info("VectorStore initialized successfully") logger.info("VectorStore initialized successfully")
# Check if the index is empty and load sample data if needed
if self.index.ntotal == 0:
logger.warning("Vector store is empty. Loading sample data...")
self._load_sample_data()
def _load_or_create_index(self) -> None: def _load_or_create_index(self) -> None:
"""Load existing index or create new one if it doesn't exist.""" """Load existing index or create new one if it doesn't exist."""
try: try:
@@ -155,10 +160,14 @@ class VectorStore:
List of result dictionaries with document content and metadata List of result dictionaries with document content and metadata
""" """
try: try:
logger.info(f"Searching vector store with query: {query[:50]}... (top_k={top_k})")
if self.index.ntotal == 0: if self.index.ntotal == 0:
logger.warning("Empty vector store, no results to return") logger.warning("Empty vector store, no results to return")
return [] return []
logger.info(f"Vector store contains {self.index.ntotal} documents")
# Generate query embedding # Generate query embedding
query_embedding = await embeddings_manager.get_query_embedding(query) query_embedding = await embeddings_manager.get_query_embedding(query)
query_embedding = query_embedding.reshape(1, -1).astype(np.float32) query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
@@ -343,5 +352,69 @@ class VectorStore:
logger.error(f"Error updating document: {str(e)}") logger.error(f"Error updating document: {str(e)}")
raise raise
def _load_sample_data(self) -> None:
"""Load sample data from past campaigns into the vector store."""
try:
# Path to past campaigns directory
campaigns_dir = Path(config.DATA_DIR) / "past_campaigns"
if not campaigns_dir.exists() or not campaigns_dir.is_dir():
logger.warning(f"Past campaigns directory not found: {campaigns_dir}")
return
# Find all JSON files in the directory
campaign_files = list(campaigns_dir.glob("*.json"))
if not campaign_files:
logger.warning("No campaign files found in past_campaigns directory")
return
# Load and process each campaign file
texts = []
metadata_list = []
for file_path in campaign_files:
try:
with open(file_path, 'r') as f:
campaign_data = json.load(f)
# Extract content and metadata
if 'content' in campaign_data:
texts.append(campaign_data['content'])
# Create metadata entry
metadata = {
'content_type': campaign_data.get('content_type', 'unknown'),
'campaign_name': campaign_data.get('metadata', {}).get('campaign_name', file_path.stem),
'source': 'past_campaign',
'file_path': str(file_path)
}
# Add performance metrics if available
if 'metadata' in campaign_data and 'performance_metrics' in campaign_data['metadata']:
metadata['performance_metrics'] = campaign_data['metadata']['performance_metrics']
metadata_list.append(metadata)
logger.debug(f"Loaded campaign from {file_path.name}")
except Exception as e:
logger.error(f"Error loading campaign file {file_path}: {str(e)}")
continue
if not texts:
logger.warning("No valid campaign content found in files")
return
# Add documents to vector store
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
doc_ids = loop.run_until_complete(self.add_documents(texts, metadata_list))
logger.info(f"Added {len(doc_ids)} past campaigns to vector store")
finally:
loop.close()
except Exception as e:
logger.error(f"Error loading sample data: {str(e)}")
# Create a singleton instance # Create a singleton instance
vector_store = VectorStore() vector_store = VectorStore()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+58 -173
View File
@@ -127,8 +127,18 @@ document.addEventListener('DOMContentLoaded', function() {
// Hide loading indicator // Hide loading indicator
loadingIndicator.classList.add('hidden'); loadingIndicator.classList.add('hidden');
// Display result // Display result with preserved formatting
resultContent.textContent = data.content; resultContent.innerHTML = data.content
.split('\n')
.map(line => {
// Convert bullet points to list items
if (line.trim().startsWith('•')) {
return `<li>${line.trim().substring(1).trim()}</li>`;
}
// Wrap non-empty lines in paragraphs
return line.trim() ? `<p>${line}</p>` : '';
})
.join('\n');
// Set alignment score // Set alignment score
const score = data.metadata.alignment_score || 0; const score = data.metadata.alignment_score || 0;
@@ -194,42 +204,39 @@ document.addEventListener('DOMContentLoaded', function() {
// Submit Improvement Feedback // Submit Improvement Feedback
if (submitImprovement) { if (submitImprovement) {
submitImprovement.addEventListener('click', function() { submitImprovement.addEventListener('click', function() {
if (!improvementFeedback.value.trim()) { const feedback = improvementFeedback.value.trim();
alert('Please enter feedback for improvement.'); if (!feedback) {
alert('Please provide improvement feedback.');
return; return;
} }
// Show loading indicator
loadingIndicator.classList.remove('hidden'); loadingIndicator.classList.remove('hidden');
// Prepare request data
const requestData = {
content: resultContent.textContent,
feedback: improvementFeedback.value
};
// Call the API
fetch(`${API_URL}/improve-content`, { fetch(`${API_URL}/improve-content`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(requestData) body: JSON.stringify({
content: resultContent.textContent,
feedback: feedback
}) })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}) })
.then(response => response.json())
.then(data => { .then(data => {
// Hide loading indicator
loadingIndicator.classList.add('hidden'); loadingIndicator.classList.add('hidden');
// Update result content // Update result content with preserved formatting
resultContent.textContent = data.improved_content; resultContent.innerHTML = data.improved_content
.split('\n')
.map(line => {
if (line.trim().startsWith('•')) {
return `<li>${line.trim().substring(1).trim()}</li>`;
}
return line.trim() ? `<p>${line}</p>` : '';
})
.join('\n');
// Hide improvement panel
improvementPanel.classList.add('hidden'); improvementPanel.classList.add('hidden');
improvementFeedback.value = ''; improvementFeedback.value = '';
}) })
@@ -520,168 +527,46 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Brand Style Tag Selection // Brand Style Tag Selection - Disabled as brand style is fixed to Adriana James' style
if (toneSelector) { // Read-only display of brand style tags
const tagElements = toneSelector.querySelectorAll('.tag');
tagElements.forEach(tag => { // Taboo Words - Read-only as brand style is fixed to Adriana James' style
tag.addEventListener('click', function() { // Disabled taboo word editing functionality
this.classList.toggle('selected'); if (tabooInput) {
}); tabooInput.disabled = true;
}); tabooInput.placeholder = "Taboo words are fixed to maintain brand consistency";
} }
if (voiceSelector) { if (addTabooBtn) {
const tagElements = voiceSelector.querySelectorAll('.tag'); addTabooBtn.disabled = true;
tagElements.forEach(tag => {
tag.addEventListener('click', function() {
this.classList.toggle('selected');
});
});
} }
// Add Taboo Word // Terminology - Read-only as brand style is fixed to Adriana James' style
if (addTabooBtn && tabooInput && tabooWords) { // Disabled terminology editing functionality
addTabooBtn.addEventListener('click', function() { if (avoidTerm) {
const word = tabooInput.value.trim(); avoidTerm.disabled = true;
if (word) { avoidTerm.placeholder = "Terminology is fixed";
const tagElement = document.createElement('span');
tagElement.classList.add('tag', 'removable');
tagElement.innerHTML = `${word}<i class="fas fa-times"></i>`;
// Add click event to remove the tag
tagElement.querySelector('i').addEventListener('click', function() {
tagElement.remove();
});
tabooWords.appendChild(tagElement);
tabooInput.value = '';
}
});
// Allow pressing Enter to add a word
tabooInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addTabooBtn.click();
}
});
} }
// Add Terminology Term if (useTerm) {
if (addTermBtn && avoidTerm && useTerm) { useTerm.disabled = true;
addTermBtn.addEventListener('click', function() { useTerm.placeholder = "Terminology is fixed";
const avoid = avoidTerm.value.trim();
const use = useTerm.value.trim();
if (avoid && use) {
const tableRow = document.createElement('div');
tableRow.classList.add('terminology-row');
tableRow.innerHTML = `
<div class="term-avoid">${avoid}</div>
<div class="term-use">${use}</div>
<div class="term-actions">
<button class="btn btn-icon"><i class="fas fa-times"></i></button>
</div>
`;
// Add click event to remove the row
tableRow.querySelector('.btn-icon').addEventListener('click', function() {
tableRow.remove();
});
// Insert before the add row
const addRow = document.querySelector('.terminology-row.add-row');
addRow.parentNode.insertBefore(tableRow, addRow);
avoidTerm.value = '';
useTerm.value = '';
}
});
// Allow pressing Enter to add a term
useTerm.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addTermBtn.click();
}
});
} }
// Save Brand Style if (addTermBtn) {
if (saveBrandStyleBtn) { addTermBtn.disabled = true;
saveBrandStyleBtn.addEventListener('click', function() {
// Collect tone tags
const selectedTones = [];
toneSelector.querySelectorAll('.tag.selected').forEach(tag => {
selectedTones.push(tag.textContent);
});
// Collect voice characteristics
const selectedVoice = [];
voiceSelector.querySelectorAll('.tag.selected').forEach(tag => {
selectedVoice.push(tag.textContent);
});
// Collect taboo words
const tabooWordsList = [];
tabooWords.querySelectorAll('.tag').forEach(tag => {
// Extract just the text without the 'x' icon
const text = tag.textContent.replace('×', '').trim();
tabooWordsList.push(text);
});
// Collect preferred terms
const preferredTerms = {};
document.querySelectorAll('.terminology-row:not(.add-row):not(.terminology-header)').forEach(row => {
const avoid = row.querySelector('.term-avoid').textContent.trim();
const use = row.querySelector('.term-use').textContent.trim();
if (avoid && use) {
preferredTerms[avoid] = use;
}
});
// Prepare request data
const requestData = {
tone: selectedTones,
voice_characteristics: selectedVoice,
taboo_words: tabooWordsList,
preferred_terms: preferredTerms
};
// Call the API
fetch(`${API_URL}/brand-style`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
alert('Brand style updated successfully!');
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating brand style. Please try again.');
});
});
} }
// Reset Brand Style // Disable remove buttons for terminology rows
if (resetBrandStyleBtn) { document.querySelectorAll('.terminology-row:not(.add-row):not(.terminology-header) .btn-icon').forEach(btn => {
resetBrandStyleBtn.addEventListener('click', function() { btn.disabled = true;
if (confirm('Are you sure you want to reset brand style to defaults?')) { btn.style.opacity = '0.5';
// In a real implementation, you would call an API to reset btn.style.cursor = 'not-allowed';
// For now, just reload the page
window.location.reload();
}
}); });
}
// Brand Style is fixed - removed save functionality
// Brand Style is fixed - removed reset functionality
// Training Tabs // Training Tabs
if (trainingTabs.length > 0) { if (trainingTabs.length > 0) {
+17 -20
View File
@@ -227,38 +227,36 @@
<section id="brand-style-page" class="page"> <section id="brand-style-page" class="page">
<div class="page-header"> <div class="page-header">
<h2>Brand Style Guidelines</h2> <h2>Brand Style Guidelines</h2>
<p>Customize the AI to match Adriana James' brand voice and tone.</p> <p>Adriana James' brand voice and tone guidelines are fixed to maintain consistency.</p>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<span>The brand style is locked to Adriana James' distinct communication style—both in her written and spoken tone. This ensures all content maintains her authentic voice.</span>
</div>
</div> </div>
<div class="brand-style-form"> <div class="brand-style-form">
<div class="form-section"> <div class="form-section">
<h3>Brand Tone</h3> <h3>Brand Tone</h3>
<p>Select the tone options that best represent the brand.</p> <p>Adriana James' distinctive tone is characterized by:</p>
<div class="tag-selector" id="tone-selector"> <div class="tag-selector read-only" id="tone-selector">
<span class="tag selected">professional</span>
<span class="tag selected">friendly</span>
<span class="tag selected">inspirational</span>
<span class="tag selected">empowering</span> <span class="tag selected">empowering</span>
<span class="tag">excited</span> <span class="tag selected">assertive</span>
<span class="tag">authoritative</span> <span class="tag selected">inspirational</span>
<span class="tag">casual</span> <span class="tag selected">direct</span>
<span class="tag">humorous</span>
</div> </div>
<p class="style-description">Her tone carries a motivational coach-like clarity, using embedded commands and cause-effect statements that inspire action.</p>
</div> </div>
<div class="form-section"> <div class="form-section">
<h3>Voice Characteristics</h3> <h3>Voice Characteristics</h3>
<p>Define the key characteristics of the brand voice.</p> <p>Adriana James speaks with these distinctive characteristics:</p>
<div class="tag-selector" id="voice-selector"> <div class="tag-selector read-only" id="voice-selector">
<span class="tag selected">clear</span> <span class="tag selected">clear</span>
<span class="tag selected">direct</span>
<span class="tag selected">empowering</span>
<span class="tag selected">confident</span> <span class="tag selected">confident</span>
<span class="tag selected">authentic</span> <span class="tag selected">conversational</span>
<span class="tag">innovative</span> <span class="tag selected">teaching</span>
<span class="tag">visionary</span>
<span class="tag">approachable</span>
</div> </div>
<p class="style-description">She speaks with conviction and clarity, using simple language to communicate profound ideas. Instead of saying "This might help you," she would say "You can do this—because your unconscious mind already knows how."</p>
</div> </div>
<div class="form-section"> <div class="form-section">
@@ -339,8 +337,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button id="save-brand-style" class="btn btn-primary">Save Brand Style</button> <p class="style-note"><i class="fas fa-lock"></i> Brand style settings are locked to maintain Adriana James' authentic voice across all content.</p>
<button id="reset-brand-style" class="btn btn-outline">Reset to Defaults</button>
</div> </div>
</div> </div>
</section> </section>
+64 -1
View File
@@ -446,9 +446,30 @@ header {
} }
.result-content { .result-content {
padding: 25px;
white-space: pre-wrap; white-space: pre-wrap;
font-family: var(--font-family);
line-height: 1.6; line-height: 1.6;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: var(--shadow-sm);
}
.result-content ul,
.result-content ol {
padding-left: 20px;
margin: 1em 0;
}
.result-content p {
margin: 1em 0;
}
/* Style bullet points */
.result-content {
margin-left: 1em;
display: list-item;
list-style-type: disc;
} }
.metadata-panel { .metadata-panel {
@@ -745,6 +766,48 @@ header {
margin-top: 10px; margin-top: 10px;
} }
.tag-selector.read-only .tag {
cursor: default;
}
.style-description {
margin-top: 15px;
font-style: italic;
color: var(--grey-600);
line-height: 1.6;
}
.style-note {
display: flex;
align-items: center;
color: var(--grey-600);
font-style: italic;
}
.style-note i {
margin-right: 8px;
color: var(--grey-500);
}
.alert {
padding: 15px;
border-radius: var(--radius-md);
margin-top: 15px;
display: flex;
align-items: center;
}
.alert i {
margin-right: 10px;
font-size: 18px;
}
.alert-info {
background-color: rgba(98, 54, 255, 0.1);
color: var(--primary-dark);
border-left: 4px solid var(--primary-color);
}
.tag { .tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+420 -970
View File
File diff suppressed because it is too large Load Diff