Alpha108 commited on
Commit
88209fc
Β·
verified Β·
1 Parent(s): bec91ec

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +135 -155
app.py CHANGED
@@ -1,14 +1,23 @@
1
  import os
2
  import re
3
  import json
4
- import time
5
  import math
6
  import streamlit as st
7
  import pandas as pd
8
 
9
- # ─────────────────────────────────────────────────────────────
10
- # 1) GROQ CLIENT (Chat Completions)
11
- # ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
12
  try:
13
  from groq import Groq
14
  except ImportError:
@@ -17,17 +26,29 @@ except ImportError:
17
  def get_groq_client():
18
  api_key = os.getenv("GROQ_API_KEY")
19
  if not api_key:
20
- raise RuntimeError("Missing GROQ_API_KEY. Add it in Space β†’ Settings β†’ Variables & Secrets.")
21
  if Groq is None:
22
- raise RuntimeError("groq package not installed. Ensure 'groq' is listed in requirements.txt.")
23
  return Groq(api_key=api_key)
24
 
25
- # Default Groq model. You can expose this via UI if you want.
26
- GROQ_MODEL = "llama-3.3-70b-versatile"
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- # ─────────────────────────────────────────────────────────────
29
- # 2) TEXT UTILITIES (dedupe, clamp)
30
- # ─────────────────────────────────────────────────────────────
31
  def dedupe_sentences(text: str) -> str:
32
  parts = re.split(r'(?<=[.!?])\s+', text.strip())
33
  seen = set()
@@ -39,25 +60,48 @@ def dedupe_sentences(text: str) -> str:
39
  out.append(p.strip())
40
  return " ".join(out).strip()
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def clamp(n, lo, hi):
43
  return max(lo, min(hi, n))
44
 
45
- # ─────────────────────────────────────────────────────────────
46
- # 3) DATASET INGEST & KEYWORD EXTRACTION
47
- # Inspired by Codebasics style-mining workflow
48
- # ─────────────────────────────────────────────────────────────
49
- # RAKE keyword extraction (simple, no heavy deps)
50
- STOPWORDS = set("""
51
- a an and the or for nor but so yet of to in on with at by from as is are was were be being been
52
- i you he she it we they them us our your their this that these those here there
53
- """.split())
 
 
 
 
 
 
 
 
 
 
54
 
55
- def simple_rake(text, min_len=3, max_len=3, top_k=10):
56
- # Split by stopwords to get candidate phrases
57
  words = re.findall(r"[A-Za-z0-9#+\-_/']+", text.lower())
58
  phrases, cur = [], []
59
  for w in words:
60
- if w in STOPWORDS:
61
  if cur:
62
  phrases.append(" ".join(cur))
63
  cur = []
@@ -65,130 +109,98 @@ def simple_rake(text, min_len=3, max_len=3, top_k=10):
65
  cur.append(w)
66
  if cur:
67
  phrases.append(" ".join(cur))
68
-
69
- # Score by sum of word degrees
70
- freq = {}
71
- degree = {}
72
  for ph in phrases:
73
- tokens = ph.split()
74
- for t in tokens:
75
  freq[t] = freq.get(t, 0) + 1
76
- degree[t] = degree.get(t, 0) + (len(tokens) - 1)
77
-
78
  scores = {}
79
  for ph in phrases:
80
  s = 0.0
81
  for t in ph.split():
82
  s += (degree.get(t, 0) + 1) / (freq.get(t, 1))
83
  scores[ph] = scores.get(ph, 0) + s
84
-
85
  ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
86
- filtered = [p for p, _ in ranked if len(p.split()) >= min_len and len(p.split()) <= max_len]
87
  return filtered[:top_k]
88
 
89
- def tfidf_keywords(texts, top_k=10):
90
- # Extremely small TF-IDF for robustness without sklearn
91
- # Build df
92
  docs = [re.findall(r"[A-Za-z0-9#+\-_/']+", t.lower()) for t in texts]
93
  vocab = {}
94
- for i, d in enumerate(docs):
95
  for w in set(d):
96
  vocab.setdefault(w, {"df": 0})
97
  vocab[w]["df"] += 1
98
  N = len(docs)
99
-
100
- def score_doc(doc):
101
  tf = {}
102
  for w in doc:
103
  tf[w] = tf.get(w, 0) + 1
104
  scores = {}
105
- for w, c in tf.items():
106
  df = vocab.get(w, {}).get("df", 1)
107
  idf = math.log((N + 1) / (df + 1)) + 1
108
- scores[w] = (c / len(doc)) * idf
109
  ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
110
- return [w for w, s in ranked[:top_k]]
 
111
 
112
- # Return a function to score a single new doc compared to corpus
113
- return lambda doc_text: score_doc(re.findall(r"[A-Za-z0-9#+\-_/']+", doc_text.lower()))
114
-
115
- def load_posts_from_file(file) -> pd.DataFrame:
116
- name = file.name.lower()
117
- if name.endswith(".csv"):
118
- df = pd.read_csv(file)
119
- elif name.endswith(".json"):
120
- df = pd.read_json(file, lines=False)
121
  else:
122
- raise ValueError("Please upload a CSV or JSON file containing LinkedIn posts.")
123
- # Normalize columns: expect a column 'text' for post content
124
- candidate_cols = [c for c in df.columns if c.lower() in ("text", "post", "content", "body")]
125
- if not candidate_cols:
126
- raise ValueError("Dataset must have a 'text' (or post/content/body) column.")
127
- if "text" not in df.columns:
128
- df["text"] = df[candidate_cols[0]]
129
- df["text"] = df["text"].fillna("").astype(str)
130
- return df[["text"]]
131
 
132
- # ─────────────────────────────────────────────────────────────
133
- # 4) PROMPT BUILDING
134
- # ─────────────────────────────────────────────────────────────
135
- def build_structured_prompt(topic, audience, tone, target_len, style_refs, keywords):
136
  style_block = "\n".join(f"- {s}" for s in style_refs[:4]) if style_refs else "- None"
137
  kw_block = ", ".join(keywords[:8]) if keywords else "N/A"
138
-
139
  return (
140
  "You are a senior LinkedIn content strategist.\n"
141
- "Write a high-quality LinkedIn post following the schema below.\n\n"
 
142
  f"Topic: \"{topic}\"\n"
143
  f"Audience: \"{audience}\"\n"
144
  f"Tone: \"{tone}\"\n"
145
- f"Target length: ~{target_len} words\n"
146
- f"Seed keywords to weave in: {kw_block}\n\n"
147
- "Reference style cues (bullet points):\n"
148
  f"{style_block}\n\n"
149
- "Constraints:\n"
150
- "- No repeated sentences or filler phrases.\n"
151
- "- Avoid clichΓ©s like β€œit's a great example of how we can make a difference in the world.”\n"
152
- "- Short sentences (< 20 words); business English; concrete examples.\n"
153
- "- Use emojis sparingly (0–2), no hashtags inside the body.\n\n"
154
- "Output format (use headers exactly):\n"
155
- "HOOK:\n"
156
- "BODY:\n"
157
- "- bullet 1\n"
158
- "- bullet 2\n"
159
- "- bullet 3\n"
160
- "TAKEAWAY:\n"
161
- "CTA:\n"
162
  )
163
 
164
- # ─────────────────────────────────────────────────────────────
165
- # 5) CALL GROQ CHAT COMPLETIONS
166
- # ─────────────────────────────────────────────────────────────
167
- def groq_generate(prompt, model=GROQ_MODEL, temperature=0.6, top_p=0.9, max_tokens=400):
168
- client = get_groq_client()
169
- resp = client.chat.completions.create(
170
- model=model,
171
- messages=[
172
- {"role": "system", "content": "You craft concise, structured LinkedIn posts."},
173
- {"role": "user", "content": prompt}
174
- ],
175
- temperature=temperature,
176
- top_p=top_p,
177
- max_tokens=max_tokens,
178
- n=1 # Groq currently supports n=1 in most cases
179
- )
180
- return resp.choices[0].message.content.strip()
181
 
182
- # ─────────────────────────────────────────────────────────────
183
- # 6) STREAMLIT UI
184
- # ─────────────────────────────────────────────────────────────
185
- st.set_page_config(page_title="LinkedIn Post Generator (Groq)", layout="centered")
186
- st.title("πŸ”— LinkedIn Post Generator β€” Dataset + Keywords + Groq")
187
- st.caption("Upload sample posts, extract keywords, and generate on Groq LLMs with structured prompts.")
188
-
189
- # Sidebar: Model and decoding controls
190
  with st.sidebar:
191
- st.subheader("Model & Decoding")
192
  model = st.selectbox(
193
  "Groq model",
194
  options=[
@@ -201,33 +213,30 @@ with st.sidebar:
201
  temperature = st.slider("Temperature", 0.1, 1.2, 0.6, 0.05)
202
  top_p = st.slider("Top-p", 0.1, 1.0, 0.9, 0.05)
203
  target_len = st.slider("Target length (words)", 60, 300, 140, 10)
204
- st.markdown("Secrets: Set GROQ_API_KEY in Space β†’ Settings β†’ Variables & Secrets.")
205
 
206
- # Main form
207
  with st.form("gen_form"):
208
  topic = st.text_input("Topic", "Generative AI for Business")
209
  tone = st.selectbox("Tone", ["Professional", "Friendly", "Inspirational", "Technical", "Concise"], index=0)
210
  audience = st.text_input("Audience", "Startup founders")
211
 
212
- st.markdown("### Upload dataset of LinkedIn posts (CSV or JSON)")
213
- uploaded = st.file_uploader("Your dataset should have a 'text' column (or 'post'/'content'/'body').", type=["csv", "json"])
214
 
215
- st.markdown("Optional: add up to 4 style cue snippets (one per line).")
216
- style_textarea = st.text_area("Style cues", value="", placeholder="e.g.\nShort, punchy hooks\nActionable bullets\nStories with numbers\nTactical CTA")
217
 
218
  submitted = st.form_submit_button("Generate Post")
219
 
220
- # Process
221
  if submitted:
222
  if not os.getenv("GROQ_API_KEY"):
223
- st.error("GROQ_API_KEY missing. Add it in Space β†’ Settings β†’ Variables & Secrets (name it exactly GROQ_API_KEY).")
224
  st.stop()
225
-
226
  if not topic.strip():
227
- st.warning("Please provide a topic.")
228
  st.stop()
229
 
230
- # Load posts
231
  posts_df = None
232
  if uploaded:
233
  try:
@@ -236,37 +245,8 @@ if submitted:
236
  st.error(f"Dataset error: {e}")
237
  st.stop()
238
 
239
- # Build keyword extractors
240
- tfidf_fn = None
241
- if posts_df is not None and len(posts_df) >= 3:
242
- # prepare a TF-IDF scorer over the corpus
243
- tfidf_fn = tfidf_keywords(posts_df["text"].tolist(), top_k=10)
244
-
245
- # Extract keywords from dataset context + topic
246
- keywords = []
247
- if posts_df is not None and len(posts_df):
248
- # Use top-k sampled posts to seed keyword candidates
249
- sample_texts = posts_df["text"].sample(min(30, len(posts_df)), random_state=42).tolist()
250
- # RAKE on concatenated sample
251
- rake_kw = simple_rake(" ".join(sample_texts + [topic]), min_len=2, max_len=3, top_k=12)
252
- keywords.extend(rake_kw)
253
- # TF-IDF relative to corpus on the topic text
254
- if tfidf_fn is not None:
255
- kw2 = tfidf_fn(topic + " " + " ".join(sample_texts[:5]))
256
- keywords.extend(kw2)
257
- else:
258
- # Fallback: RAKE on topic only
259
- keywords = simple_rake(topic, min_len=1, max_len=2, top_k=8)
260
-
261
- # Normalize and dedupe keywords
262
- norm_kw = []
263
- seen = set()
264
- for k in keywords:
265
- k2 = re.sub(r"\s+", " ", k.strip().lower())
266
- if k2 and k2 not in seen:
267
- seen.add(k2)
268
- norm_kw.append(k2)
269
- keywords = norm_kw[:12]
270
 
271
  # Style cues
272
  style_refs = []
@@ -274,8 +254,8 @@ if submitted:
274
  style_refs = [s.strip() for s in style_textarea.splitlines() if s.strip()]
275
  style_refs = style_refs[:4]
276
 
277
- # Prompt
278
- prompt = build_structured_prompt(
279
  topic=topic,
280
  audience=audience,
281
  tone=tone,
@@ -286,7 +266,6 @@ if submitted:
286
 
287
  with st.spinner("Generating with Groq..."):
288
  try:
289
- # Convert words to approximate tokens for cap (rough 1.4x)
290
  max_tokens = clamp(int(target_len * 1.6) + 120, 200, 1200)
291
  txt = groq_generate(
292
  prompt=prompt,
@@ -295,10 +274,11 @@ if submitted:
295
  top_p=top_p,
296
  max_tokens=max_tokens
297
  )
298
- txt = dedupe_sentences(txt)
 
299
  st.success("Generated Post")
300
  st.write(txt)
301
- st.download_button("Download post (.txt)", txt, file_name="linkedin_post.txt")
302
  with st.expander("Debug: keywords & prompt"):
303
  st.write({"keywords": keywords, "style_refs": style_refs})
304
  st.code(prompt)
 
1
  import os
2
  import re
3
  import json
 
4
  import math
5
  import streamlit as st
6
  import pandas as pd
7
 
8
+ # =========================
9
+ # 0) CONFIG / CONSTANTS
10
+ # =========================
11
+ GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile" # Sidebar lets you change
12
+ MAX_KEYWORDS = 12
13
+ SEED_STOPWORDS = set("""
14
+ a an and the or for nor but so yet of to in on with at by from as is are was were be being been
15
+ i you he she it we they them us our your their this that these those here there
16
+ """.split())
17
+
18
+ # =========================
19
+ # 1) GROQ CLIENT
20
+ # =========================
21
  try:
22
  from groq import Groq
23
  except ImportError:
 
26
  def get_groq_client():
27
  api_key = os.getenv("GROQ_API_KEY")
28
  if not api_key:
29
+ raise RuntimeError("Missing GROQ_API_KEY. Set it in Space β†’ Settings β†’ Variables & Secrets.")
30
  if Groq is None:
31
+ raise RuntimeError("Package 'groq' not installed. Add 'groq' to requirements.txt.")
32
  return Groq(api_key=api_key)
33
 
34
+ def groq_generate(prompt, model, temperature, top_p, max_tokens):
35
+ client = get_groq_client()
36
+ resp = client.chat.completions.create(
37
+ model=model,
38
+ messages=[
39
+ {"role": "system", "content": "You craft concise, insightful LinkedIn posts that feel original and practical."},
40
+ {"role": "user", "content": prompt}
41
+ ],
42
+ temperature=temperature,
43
+ top_p=top_p,
44
+ max_tokens=max_tokens,
45
+ n=1
46
+ )
47
+ return resp.choices[0].message.content.strip()
48
 
49
+ # =========================
50
+ # 2) TEXT UTILS
51
+ # =========================
52
  def dedupe_sentences(text: str) -> str:
53
  parts = re.split(r'(?<=[.!?])\s+', text.strip())
54
  seen = set()
 
60
  out.append(p.strip())
61
  return " ".join(out).strip()
62
 
63
+ def strip_labels(text: str) -> str:
64
+ patterns = [
65
+ r'^\s*hook:\s*', r'^\s*body:\s*', r'^\s*takeaway:\s*', r'^\s*cta:\s*',
66
+ r'^\s*Hook:\s*', r'^\s*Body:\s*', r'^\s*Takeaway:\s*', r'^\s*CTA:\s*'
67
+ ]
68
+ lines = text.splitlines()
69
+ cleaned = []
70
+ for line in lines:
71
+ L = line
72
+ for p in patterns:
73
+ L = re.sub(p, '', L)
74
+ cleaned.append(L)
75
+ return "\n".join(cleaned).strip()
76
+
77
  def clamp(n, lo, hi):
78
  return max(lo, min(hi, n))
79
 
80
+ # =========================
81
+ # 3) DATA INGEST & KEYWORDS
82
+ # =========================
83
+ def load_posts_from_file(file) -> pd.DataFrame:
84
+ name = file.name.lower()
85
+ if name.endswith(".csv"):
86
+ df = pd.read_csv(file)
87
+ elif name.endswith(".json"):
88
+ df = pd.read_json(file, lines=False)
89
+ else:
90
+ raise ValueError("Upload a CSV or JSON file containing LinkedIn posts.")
91
+ # Normalize to 'text' column
92
+ candidate = [c for c in df.columns if c.lower() in ("text", "post", "content", "body")]
93
+ if not candidate:
94
+ raise ValueError("Dataset must have a 'text' (or post/content/body) column.")
95
+ if "text" not in df.columns:
96
+ df["text"] = df[candidate[0]]
97
+ df["text"] = df["text"].fillna("").astype(str)
98
+ return df[["text"]]
99
 
100
+ def simple_rake(text, min_len=2, max_len=3, top_k=12):
 
101
  words = re.findall(r"[A-Za-z0-9#+\-_/']+", text.lower())
102
  phrases, cur = [], []
103
  for w in words:
104
+ if w in SEED_STOPWORDS:
105
  if cur:
106
  phrases.append(" ".join(cur))
107
  cur = []
 
109
  cur.append(w)
110
  if cur:
111
  phrases.append(" ".join(cur))
112
+ # Score by frequency+degree
113
+ freq, degree = {}, {}
 
 
114
  for ph in phrases:
115
+ toks = ph.split()
116
+ for t in toks:
117
  freq[t] = freq.get(t, 0) + 1
118
+ degree[t] = degree.get(t, 0) + (len(toks) - 1)
 
119
  scores = {}
120
  for ph in phrases:
121
  s = 0.0
122
  for t in ph.split():
123
  s += (degree.get(t, 0) + 1) / (freq.get(t, 1))
124
  scores[ph] = scores.get(ph, 0) + s
 
125
  ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
126
+ filtered = [p for p, _ in ranked if min_len <= len(p.split()) <= max_len]
127
  return filtered[:top_k]
128
 
129
+ def tfidf_keywords_builder(texts, top_k=10):
 
 
130
  docs = [re.findall(r"[A-Za-z0-9#+\-_/']+", t.lower()) for t in texts]
131
  vocab = {}
132
+ for d in docs:
133
  for w in set(d):
134
  vocab.setdefault(w, {"df": 0})
135
  vocab[w]["df"] += 1
136
  N = len(docs)
137
+ def score_doc(text):
138
+ doc = re.findall(r"[A-Za-z0-9#+\-_/']+", text.lower())
139
  tf = {}
140
  for w in doc:
141
  tf[w] = tf.get(w, 0) + 1
142
  scores = {}
143
+ for w, cnt in tf.items():
144
  df = vocab.get(w, {}).get("df", 1)
145
  idf = math.log((N + 1) / (df + 1)) + 1
146
+ scores[w] = (cnt / len(doc)) * idf
147
  ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
148
+ return [w for w, _ in ranked[:top_k]]
149
+ return score_doc
150
 
151
+ def extract_keywords(topic: str, posts_df: pd.DataFrame | None):
152
+ if posts_df is not None and len(posts_df):
153
+ sample = posts_df["text"].sample(min(30, len(posts_df)), random_state=42).tolist()
154
+ rake_kw = simple_rake(" ".join(sample + [topic]), min_len=2, max_len=3, top_k=MAX_KEYWORDS)
155
+ tfidf_fn = tfidf_keywords_builder(posts_df["text"].tolist(), top_k=MAX_KEYWORDS//2)
156
+ kw2 = tfidf_fn(topic + " " + " ".join(sample[:5]))
157
+ all_kw = rake_kw + kw2
 
 
158
  else:
159
+ all_kw = simple_rake(topic, min_len=1, max_len=2, top_k=8)
160
+ seen, out = set(), []
161
+ for k in all_kw:
162
+ k2 = re.sub(r"\s+", " ", k.strip().lower())
163
+ if k2 and k2 not in seen:
164
+ seen.add(k2)
165
+ out.append(k2)
166
+ return out[:MAX_KEYWORDS]
 
167
 
168
+ # =========================
169
+ # 4) PROMPT (PLAIN OUTPUT)
170
+ # =========================
171
+ def build_viral_prompt(topic, audience, tone, target_len, style_refs, keywords):
172
  style_block = "\n".join(f"- {s}" for s in style_refs[:4]) if style_refs else "- None"
173
  kw_block = ", ".join(keywords[:8]) if keywords else "N/A"
 
174
  return (
175
  "You are a senior LinkedIn content strategist.\n"
176
+ "Objective: Write a viral, insightful LinkedIn post as plain text only (no section headers, no labels), "
177
+ f"around {target_len} words, for the audience and topic below.\n\n"
178
  f"Topic: \"{topic}\"\n"
179
  f"Audience: \"{audience}\"\n"
180
  f"Tone: \"{tone}\"\n"
181
+ f"Keywords to naturally weave in: {kw_block}\n\n"
182
+ "Style cues (reflect these, do not list them):\n"
 
183
  f"{style_block}\n\n"
184
+ "Apply silently (do not mention these rules):\n"
185
+ "- Open with a curiosity-driving first line.\n"
186
+ "- Use short sentences and short paragraphs.\n"
187
+ "- Include 3–5 concrete insights, examples, or steps (bullets allowed, but no section labels).\n"
188
+ "- Be specific, novel, and practical; avoid clichΓ©s and filler.\n"
189
+ "- Use up to 2 emojis; add 2–4 niche hashtags only at the very end (optional).\n"
190
+ "- Never output headings like HOOK/BODY/TAKEAWAY/CTA.\n"
191
+ "- Do not repeat the phrase: β€œit's a great example of how we can make a difference in the world.”\n\n"
192
+ "Output: A single cohesive LinkedIn post as plain text only. No headings. No metadata. No explanations."
 
 
 
 
193
  )
194
 
195
+ # =========================
196
+ # 5) STREAMLIT UI
197
+ # =========================
198
+ st.set_page_config(page_title="LinkedIn Post Generator β€” Groq", layout="centered")
199
+ st.title("πŸ”— LinkedIn Post Generator β€” Dataset Keywords + Groq")
200
+ st.caption("Upload sample posts, extract keywords, and generate plain-text viral posts via Groq.")
 
 
 
 
 
 
 
 
 
 
 
201
 
 
 
 
 
 
 
 
 
202
  with st.sidebar:
203
+ st.subheader("Groq & Decoding")
204
  model = st.selectbox(
205
  "Groq model",
206
  options=[
 
213
  temperature = st.slider("Temperature", 0.1, 1.2, 0.6, 0.05)
214
  top_p = st.slider("Top-p", 0.1, 1.0, 0.9, 0.05)
215
  target_len = st.slider("Target length (words)", 60, 300, 140, 10)
216
+ st.markdown("Set GROQ_API_KEY in Space β†’ Settings β†’ Variables & Secrets.")
217
 
 
218
  with st.form("gen_form"):
219
  topic = st.text_input("Topic", "Generative AI for Business")
220
  tone = st.selectbox("Tone", ["Professional", "Friendly", "Inspirational", "Technical", "Concise"], index=0)
221
  audience = st.text_input("Audience", "Startup founders")
222
 
223
+ st.markdown("### Upload dataset (CSV/JSON) of LinkedIn posts")
224
+ uploaded = st.file_uploader("Dataset must include a 'text' (or 'post'/'content'/'body') column.", type=["csv", "json"])
225
 
226
+ st.markdown("Optional: add up to 4 style cues (one per line).")
227
+ style_textarea = st.text_area("Style cues", value="", placeholder="Short hooks\nActionable bullets\nStories with numbers\nTactical CTA")
228
 
229
  submitted = st.form_submit_button("Generate Post")
230
 
 
231
  if submitted:
232
  if not os.getenv("GROQ_API_KEY"):
233
+ st.error("GROQ_API_KEY missing. Add it in Space β†’ Settings β†’ Variables & Secrets.")
234
  st.stop()
 
235
  if not topic.strip():
236
+ st.warning("Please enter a topic.")
237
  st.stop()
238
 
239
+ # Load dataset if provided
240
  posts_df = None
241
  if uploaded:
242
  try:
 
245
  st.error(f"Dataset error: {e}")
246
  st.stop()
247
 
248
+ # Extract keywords
249
+ keywords = extract_keywords(topic, posts_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
  # Style cues
252
  style_refs = []
 
254
  style_refs = [s.strip() for s in style_textarea.splitlines() if s.strip()]
255
  style_refs = style_refs[:4]
256
 
257
+ # Build prompt and generate
258
+ prompt = build_viral_prompt(
259
  topic=topic,
260
  audience=audience,
261
  tone=tone,
 
266
 
267
  with st.spinner("Generating with Groq..."):
268
  try:
 
269
  max_tokens = clamp(int(target_len * 1.6) + 120, 200, 1200)
270
  txt = groq_generate(
271
  prompt=prompt,
 
274
  top_p=top_p,
275
  max_tokens=max_tokens
276
  )
277
+ # Clean and display
278
+ txt = dedupe_sentences(strip_labels(txt))
279
  st.success("Generated Post")
280
  st.write(txt)
281
+ st.download_button("Download (.txt)", txt, file_name="linkedin_post.txt")
282
  with st.expander("Debug: keywords & prompt"):
283
  st.write({"keywords": keywords, "style_refs": style_refs})
284
  st.code(prompt)