Tom Claude commited on
Commit
1e2d815
·
1 Parent(s): 53061be

feat: add progress indicators with generator pattern

Browse files

Convert search_topics() and generate_script() to generators that yield
intermediate status messages. This enables Gradio's progress bar and
spinner to display during long-running operations.

- Use yield instead of return to create UI update checkpoints
- Add gr.Progress() calls for percentage-based progress bar
- Enable queue before event handlers for proper async behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (7) hide show
  1. .gitignore +43 -0
  2. app.py +318 -0
  3. requirements.txt +14 -0
  4. src/__init__.py +21 -0
  5. src/llm_client.py +191 -0
  6. src/prompts.py +125 -0
  7. src/vectorstore.py +418 -0
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables (contains secrets)
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # Virtual environments
27
+ venv/
28
+ ENV/
29
+ env/
30
+ .venv/
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Gradio
43
+ flagged/
app.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NewPress AI - Johnny Harris Script Assistant
3
+
4
+ A Gradio app that uses a Supabase vector database of Johnny Harris transcripts to:
5
+ 1. Search if topics have been covered before
6
+ 2. Generate scripts in Johnny's voice from bullet points
7
+ """
8
+
9
+ import os
10
+ import gradio as gr
11
+ from dotenv import load_dotenv
12
+
13
+ from src.vectorstore import TranscriptVectorStore, create_vectorstore
14
+ from src.llm_client import InferenceProviderClient, create_llm_client
15
+ from src.prompts import (
16
+ TOPIC_SEARCH_SYSTEM_PROMPT,
17
+ SCRIPT_SYSTEM_PROMPT,
18
+ get_topic_search_prompt,
19
+ get_script_prompt
20
+ )
21
+
22
+ # Load environment variables
23
+ load_dotenv()
24
+
25
+ # Initialize components (lazy loading)
26
+ vectorstore = None
27
+ llm_client = None
28
+
29
+
30
+ def get_vectorstore() -> TranscriptVectorStore:
31
+ """Get or create the vector store instance"""
32
+ global vectorstore
33
+ if vectorstore is None:
34
+ vectorstore = create_vectorstore()
35
+ return vectorstore
36
+
37
+
38
+ def get_llm_client() -> InferenceProviderClient:
39
+ """Get or create the LLM client instance"""
40
+ global llm_client
41
+ if llm_client is None:
42
+ llm_client = create_llm_client()
43
+ return llm_client
44
+
45
+
46
+ # =============================================================================
47
+ # TAB 1: TOPIC SEARCH
48
+ # =============================================================================
49
+
50
+ def expand_query(query: str) -> list:
51
+ """Use LLM to generate related search terms for broader coverage"""
52
+ try:
53
+ llm = get_llm_client()
54
+ prompt = f"""Given this search query about Johnny Harris video topics: "{query}"
55
+
56
+ Generate 3-5 related search terms that might find relevant videos.
57
+ Think about: related topics, geographic regions, historical events, or concepts that might be covered.
58
+ Return ONLY the terms, one per line, no numbering or explanation."""
59
+
60
+ response = llm.generate(prompt, max_tokens=100, temperature=0.3)
61
+ terms = [t.strip() for t in response.strip().split('\n') if t.strip()]
62
+ return [query] + terms[:5]
63
+ except Exception:
64
+ return [query]
65
+
66
+
67
+ def search_topics(query: str, progress=gr.Progress()):
68
+ """
69
+ Generator that yields progress updates during search.
70
+ Uses LLM query expansion for broader, more relevant results.
71
+
72
+ Args:
73
+ query: User's topic or question
74
+ progress: Gradio progress tracker
75
+
76
+ Yields:
77
+ Progress status messages, then final search results
78
+ """
79
+ if not query or not query.strip():
80
+ yield "Please enter a topic or question to search."
81
+ return
82
+
83
+ try:
84
+ vs = get_vectorstore()
85
+
86
+ # Expand query using LLM
87
+ progress(0.1, desc="Expanding search query...")
88
+ yield "Expanding search query..."
89
+ search_terms = expand_query(query.strip())
90
+
91
+ # Search with each term and collect results
92
+ all_results = []
93
+ total_terms = len(search_terms)
94
+ for i, term in enumerate(search_terms):
95
+ pct = 0.2 + (0.5 * (i / total_terms))
96
+ progress(pct, desc=f"Searching: {term[:30]}...")
97
+ yield f"Searching: {term[:30]}..."
98
+ results = vs.similarity_search(
99
+ query=term,
100
+ k=20,
101
+ match_threshold=0.1
102
+ )
103
+ all_results.extend(results)
104
+
105
+ progress(0.8, desc="Processing results...")
106
+ yield "Processing results..."
107
+
108
+ # Deduplicate by video title, keep highest similarity score
109
+ seen = {}
110
+ for r in all_results:
111
+ if r.title not in seen or r.similarity > seen[r.title].similarity:
112
+ seen[r.title] = r
113
+
114
+ # Sort by similarity and get top results
115
+ unique_results = sorted(seen.values(), key=lambda x: x.similarity, reverse=True)[:15]
116
+
117
+ if not unique_results:
118
+ yield f"No matching content found for: **{query}**\n\nThis topic may not have been covered yet, or try rephrasing your search."
119
+ return
120
+
121
+ # Format results for display
122
+ output = vs.format_results_for_display(unique_results)
123
+
124
+ search_info = f"*Searched: {', '.join(search_terms)}*\n\n"
125
+ progress(1.0, desc="Done!")
126
+ yield f"## Search Results for: \"{query}\"\n\n{search_info}{output}"
127
+
128
+ except Exception as e:
129
+ yield f"Error searching: {str(e)}"
130
+
131
+
132
+ # =============================================================================
133
+ # TAB 2: SCRIPT PRODUCTION
134
+ # =============================================================================
135
+
136
+ def generate_script(user_notes: str, max_context_chunks: int = 100, progress=gr.Progress()):
137
+ """
138
+ Generator that yields progress updates during script generation.
139
+
140
+ Args:
141
+ user_notes: User's bullet points and notes
142
+ max_context_chunks: Number of style reference chunks to use
143
+ progress: Gradio progress tracker
144
+
145
+ Yields:
146
+ Progress status messages, then final generated script
147
+ """
148
+ if not user_notes or not user_notes.strip():
149
+ yield "Please enter your bullet points or notes to transform into a script."
150
+ return
151
+
152
+ try:
153
+ progress(0.05, desc="Gathering style references...")
154
+ yield "Gathering style references..."
155
+ vs = get_vectorstore()
156
+ llm = get_llm_client()
157
+
158
+ progress(0.15, desc="Searching knowledge base...")
159
+ yield "Searching knowledge base for style references..."
160
+ context_chunks = vs.get_bulk_style_context(
161
+ topic_query=user_notes.strip(),
162
+ max_chunks=max_context_chunks,
163
+ topic_relevant_ratio=0.3
164
+ )
165
+
166
+ progress(0.35, desc="Preparing context...")
167
+ yield "Preparing context for the LLM..."
168
+ context = vs.format_context_for_llm(context_chunks) if context_chunks else ""
169
+
170
+ progress(0.5, desc="Building prompt...")
171
+ yield "Building prompt..."
172
+ prompt_template = get_script_prompt()
173
+ prompt = prompt_template.format(
174
+ user_input=user_notes.strip(),
175
+ context=context
176
+ )
177
+
178
+ progress(0.7, desc="Generating script (30-60 seconds)...")
179
+ yield "Generating script (this may take 30-60 seconds)..."
180
+ script = llm.generate(
181
+ prompt=prompt,
182
+ system_prompt=SCRIPT_SYSTEM_PROMPT,
183
+ temperature=0.7,
184
+ max_tokens=2000
185
+ )
186
+
187
+ progress(1.0, desc="Complete!")
188
+ yield f"## Generated Script\n\n{script.strip()}"
189
+
190
+ except Exception as e:
191
+ yield f"**Error:** {str(e)}"
192
+
193
+
194
+ # =============================================================================
195
+ # GRADIO INTERFACE
196
+ # =============================================================================
197
+
198
+ def create_app():
199
+ """Create and configure the Gradio application"""
200
+
201
+ with gr.Blocks(
202
+ title="NewPress AI - Johnny Harris Script Assistant",
203
+ theme=gr.themes.Soft()
204
+ ) as app:
205
+ app.queue() # Enable queue before defining event handlers for progress to work
206
+
207
+ gr.Markdown("""
208
+ # NewPress AI
209
+ ### Johnny Harris Script Assistant
210
+
211
+ Use Johnny's archive of hundreds of video transcripts to:
212
+ - **Search** if a topic has been covered before
213
+ - **Generate** scripts in Johnny's voice from your notes
214
+ """)
215
+
216
+ with gr.Tabs():
217
+ # =================================================================
218
+ # TAB 1: TOPIC SEARCH
219
+ # =================================================================
220
+ with gr.TabItem("Topic Search"):
221
+ gr.Markdown("""
222
+ ### Has Johnny covered this topic?
223
+
224
+ Search the archive to see if a topic has been addressed in previous videos.
225
+ """)
226
+
227
+ with gr.Row():
228
+ with gr.Column(scale=3):
229
+ topic_input = gr.Textbox(
230
+ label="Topic or Question",
231
+ placeholder="e.g., Why do borders exist? or US immigration policy",
232
+ lines=2
233
+ )
234
+ with gr.Column(scale=1):
235
+ search_btn = gr.Button("Search", variant="primary", size="lg")
236
+
237
+ search_output = gr.Markdown(label="Search Results", value="Search results will appear here...")
238
+
239
+ search_btn.click(
240
+ fn=search_topics,
241
+ inputs=[topic_input],
242
+ outputs=[search_output],
243
+ show_progress="full"
244
+ )
245
+
246
+ topic_input.submit(
247
+ fn=search_topics,
248
+ inputs=[topic_input],
249
+ outputs=[search_output],
250
+ show_progress="full"
251
+ )
252
+
253
+ # =================================================================
254
+ # TAB 2: SCRIPT PRODUCTION
255
+ # =================================================================
256
+ with gr.TabItem("Script Production"):
257
+ gr.Markdown("""
258
+ ### Transform your ideas into Johnny's voice
259
+
260
+ Enter your bullet points, notes, or rough ideas. The AI will analyze
261
+ Johnny's entire archive of scripts and generate a draft in his signature style.
262
+ """)
263
+
264
+ with gr.Row():
265
+ with gr.Column():
266
+ notes_input = gr.Textbox(
267
+ label="Your Notes & Bullet Points",
268
+ placeholder="""Enter your ideas, for example:
269
+
270
+ - Topic: Why shipping containers changed the world
271
+ - Key points:
272
+ - Before containers, loading ships took weeks
273
+ - Malcolm McLean invented the standard container in 1956
274
+ - Transformed global trade
275
+ - Connection to globalization and supply chains
276
+ - Angle: The hidden infrastructure we never think about""",
277
+ lines=12
278
+ )
279
+
280
+ with gr.Row():
281
+ context_slider = gr.Slider(
282
+ minimum=20,
283
+ maximum=200,
284
+ value=100,
285
+ step=10,
286
+ label="Style Reference Depth",
287
+ info="More excerpts = better style matching, but slower"
288
+ )
289
+ generate_btn = gr.Button("Generate Script", variant="primary", size="lg")
290
+
291
+ script_output = gr.Markdown(label="Generated Script", value="Generated script will appear here...") # shows progress + final script
292
+
293
+ generate_btn.click(
294
+ fn=generate_script,
295
+ inputs=[notes_input, context_slider],
296
+ outputs=[script_output],
297
+ show_progress="full"
298
+ )
299
+
300
+ gr.Markdown("""
301
+ ---
302
+ *Powered by Johnny Harris's transcript archive, Jina AI embeddings, and Qwen-2.5-72B*
303
+ """)
304
+
305
+ return app
306
+
307
+
308
+ # =============================================================================
309
+ # MAIN
310
+ # =============================================================================
311
+
312
+ if __name__ == "__main__":
313
+ app = create_app()
314
+ app.launch(
315
+ server_name="0.0.0.0",
316
+ server_port=7860,
317
+ share=False
318
+ )
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gradio for UI
2
+ gradio>=4.0.0
3
+
4
+ # Supabase client for vector store
5
+ supabase>=2.0.0
6
+
7
+ # Hugging Face Inference (for LLM)
8
+ huggingface-hub>=0.20.0
9
+
10
+ # Environment variables
11
+ python-dotenv>=1.0.0
12
+
13
+ # HTTP requests (for Jina API)
14
+ requests
src/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NewPress AI - Johnny Harris Script Assistant"""
2
+
3
+ from .vectorstore import TranscriptVectorStore, create_vectorstore
4
+ from .llm_client import InferenceProviderClient, create_llm_client
5
+ from .prompts import (
6
+ TOPIC_SEARCH_SYSTEM_PROMPT,
7
+ SCRIPT_SYSTEM_PROMPT,
8
+ SCRIPT_PROMPT_TEMPLATE,
9
+ get_script_prompt
10
+ )
11
+
12
+ __all__ = [
13
+ "TranscriptVectorStore",
14
+ "create_vectorstore",
15
+ "InferenceProviderClient",
16
+ "create_llm_client",
17
+ "TOPIC_SEARCH_SYSTEM_PROMPT",
18
+ "SCRIPT_SYSTEM_PROMPT",
19
+ "SCRIPT_PROMPT_TEMPLATE",
20
+ "get_script_prompt"
21
+ ]
src/llm_client.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM client for Hugging Face Inference API"""
2
+
3
+ import os
4
+ from typing import Iterator, Optional
5
+ from huggingface_hub import InferenceClient
6
+
7
+
8
+ class InferenceProviderClient:
9
+ """Client for Hugging Face Inference API"""
10
+
11
+ def __init__(
12
+ self,
13
+ model: str = "Qwen/Qwen2.5-72B-Instruct",
14
+ api_key: Optional[str] = None,
15
+ temperature: float = 0.7,
16
+ max_tokens: int = 2000
17
+ ):
18
+ """
19
+ Initialize the Inference client
20
+
21
+ Args:
22
+ model: Model identifier (default: Qwen2.5-72B-Instruct)
23
+ api_key: HuggingFace API token (defaults to HF_TOKEN env var)
24
+ temperature: Sampling temperature (0.0 to 1.0)
25
+ max_tokens: Maximum tokens to generate
26
+ """
27
+ self.model = model
28
+ self.temperature = temperature
29
+ self.max_tokens = max_tokens
30
+
31
+ api_key = api_key or os.getenv("HF_TOKEN")
32
+ if not api_key:
33
+ raise ValueError("HF_TOKEN environment variable must be set or api_key provided")
34
+
35
+ self.client = InferenceClient(token=api_key)
36
+
37
+ def generate(
38
+ self,
39
+ prompt: str,
40
+ system_prompt: Optional[str] = None,
41
+ temperature: Optional[float] = None,
42
+ max_tokens: Optional[int] = None
43
+ ) -> str:
44
+ """
45
+ Generate a response from the LLM
46
+
47
+ Args:
48
+ prompt: User prompt
49
+ system_prompt: Optional system prompt
50
+ temperature: Override default temperature
51
+ max_tokens: Override default max tokens
52
+
53
+ Returns:
54
+ Generated text response
55
+ """
56
+ messages = []
57
+
58
+ if system_prompt:
59
+ messages.append({"role": "system", "content": system_prompt})
60
+
61
+ messages.append({"role": "user", "content": prompt})
62
+
63
+ response = self.client.chat_completion(
64
+ model=self.model,
65
+ messages=messages,
66
+ temperature=temperature or self.temperature,
67
+ max_tokens=max_tokens or self.max_tokens
68
+ )
69
+
70
+ return response.choices[0].message.content
71
+
72
+ def generate_stream(
73
+ self,
74
+ prompt: str,
75
+ system_prompt: Optional[str] = None,
76
+ temperature: Optional[float] = None,
77
+ max_tokens: Optional[int] = None
78
+ ) -> Iterator[str]:
79
+ """
80
+ Generate a streaming response from the LLM
81
+
82
+ Args:
83
+ prompt: User prompt
84
+ system_prompt: Optional system prompt
85
+ temperature: Override default temperature
86
+ max_tokens: Override default max tokens
87
+
88
+ Yields:
89
+ Text chunks as they are generated
90
+ """
91
+ messages = []
92
+
93
+ if system_prompt:
94
+ messages.append({"role": "system", "content": system_prompt})
95
+
96
+ messages.append({"role": "user", "content": prompt})
97
+
98
+ stream = self.client.chat_completion(
99
+ model=self.model,
100
+ messages=messages,
101
+ temperature=temperature or self.temperature,
102
+ max_tokens=max_tokens or self.max_tokens,
103
+ stream=True
104
+ )
105
+
106
+ for chunk in stream:
107
+ try:
108
+ if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
109
+ if hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
110
+ if chunk.choices[0].delta.content is not None:
111
+ yield chunk.choices[0].delta.content
112
+ except (IndexError, AttributeError):
113
+ continue
114
+
115
+ def chat(
116
+ self,
117
+ messages: list[dict],
118
+ temperature: Optional[float] = None,
119
+ max_tokens: Optional[int] = None,
120
+ stream: bool = False
121
+ ):
122
+ """
123
+ Multi-turn chat completion
124
+
125
+ Args:
126
+ messages: List of message dicts with 'role' and 'content'
127
+ temperature: Override default temperature
128
+ max_tokens: Override default max tokens
129
+ stream: Whether to stream the response
130
+
131
+ Returns:
132
+ Response text (or iterator if stream=True)
133
+ """
134
+ response = self.client.chat_completion(
135
+ model=self.model,
136
+ messages=messages,
137
+ temperature=temperature or self.temperature,
138
+ max_tokens=max_tokens or self.max_tokens,
139
+ stream=stream
140
+ )
141
+
142
+ if stream:
143
+ def stream_generator():
144
+ for chunk in response:
145
+ try:
146
+ if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
147
+ if hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
148
+ if chunk.choices[0].delta.content is not None:
149
+ yield chunk.choices[0].delta.content
150
+ except (IndexError, AttributeError):
151
+ continue
152
+ return stream_generator()
153
+ else:
154
+ return response.choices[0].message.content
155
+
156
+
157
+ def create_llm_client(
158
+ model: str = "Qwen/Qwen2.5-72B-Instruct",
159
+ temperature: float = 0.7,
160
+ max_tokens: int = 2000
161
+ ) -> InferenceProviderClient:
162
+ """
163
+ Factory function to create and return a configured LLM client
164
+
165
+ Args:
166
+ model: Model identifier
167
+ temperature: Sampling temperature
168
+ max_tokens: Maximum tokens to generate
169
+
170
+ Returns:
171
+ Configured InferenceProviderClient
172
+ """
173
+ return InferenceProviderClient(
174
+ model=model,
175
+ temperature=temperature,
176
+ max_tokens=max_tokens
177
+ )
178
+
179
+
180
+ # Available models
181
+ AVAILABLE_MODELS = {
182
+ "qwen-72b": "Qwen/Qwen2.5-72B-Instruct",
183
+ "llama-3.1-8b": "meta-llama/Llama-3.1-8B-Instruct",
184
+ "llama-3-8b": "meta-llama/Meta-Llama-3-8B-Instruct",
185
+ "mistral-7b": "mistralai/Mistral-7B-Instruct-v0.3",
186
+ }
187
+
188
+
189
+ def get_model_identifier(model_name: str) -> str:
190
+ """Get full model identifier from short name"""
191
+ return AVAILABLE_MODELS.get(model_name, AVAILABLE_MODELS["qwen-72b"])
src/prompts.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt templates for Johnny Harris Script Assistant"""
2
+
3
+
4
+ # =============================================================================
5
+ # TAB 1: TOPIC SEARCH PROMPTS
6
+ # =============================================================================
7
+
8
+ TOPIC_SEARCH_SYSTEM_PROMPT = """You analyze search results from Johnny Harris's video archive.
9
+
10
+ Given matching transcript excerpts, provide a clear summary of:
11
+ 1. Which videos covered this topic (with titles)
12
+ 2. Key points and perspectives from each relevant video
13
+ 3. How thoroughly the topic was explored
14
+
15
+ Be concise but informative. Help the user understand what content already exists on this topic."""
16
+
17
+
18
+ TOPIC_SEARCH_PROMPT_TEMPLATE = """USER'S QUESTION: {query}
19
+
20
+ MATCHING CONTENT FROM JOHNNY'S ARCHIVE:
21
+ {context}
22
+
23
+ Based on these search results, summarize:
24
+ 1. Which videos address this topic
25
+ 2. Key points covered in each
26
+ 3. Overall coverage assessment - has Johnny covered this thoroughly, partially, or not at all?
27
+
28
+ Keep your response concise and actionable."""
29
+
30
+
31
+ # =============================================================================
32
+ # TAB 2: SCRIPT PRODUCTION PROMPTS
33
+ # =============================================================================
34
+
35
+ SCRIPT_SYSTEM_PROMPT = """You are a script writing assistant that has deeply studied Johnny Harris's style.
36
+
37
+ JOHNNY'S VOICE CHARACTERISTICS (derived from extensive analysis of his work):
38
+
39
+ **Narrative Structure:**
40
+ - Opens with a hook - a provocative question, surprising fact, or personal moment
41
+ - Builds tension through questions: "But here's the thing...", "So why does this matter?"
42
+ - Uses the "zoom out" technique - starts specific, expands to bigger picture
43
+ - Weaves between personal story and broader research/data
44
+ - Ends with reflection or call to think differently
45
+
46
+ **Language Patterns:**
47
+ - Direct address: "I want to show you something", "Let me explain"
48
+ - Conversational markers: "the thing is...", "here's what's interesting...", "and this is where it gets wild"
49
+ - Short punchy sentences followed by longer explanatory ones
50
+ - Rhetorical questions that pull the viewer in
51
+ - Admits uncertainty: "I don't fully understand this yet", "I'm still wrestling with this"
52
+
53
+ **Tone:**
54
+ - Curious and genuinely excited about learning
55
+ - Slightly irreverent but deeply researched
56
+ - Personal without being self-indulgent
57
+ - Acknowledges complexity without being academic
58
+ - Finds the human story in geopolitics/data
59
+
60
+ Your job is to transform the user's bullet points and notes into a script draft that authentically sounds like Johnny wrote it. Study the provided transcript excerpts carefully - they are your primary style reference. Do not include visual cues, bracketed notes, or stage directions—return narrative script text only.
61
+
62
+ **FORMAT: YouTube Short (under 3 minutes)**
63
+ - Target length: 400-500 words (roughly 2-3 minutes when spoken)
64
+ - Must hook immediately - no slow buildup
65
+ - Punchier pacing than long-form content
66
+ - One core idea, explored quickly but compellingly
67
+ - End with a memorable takeaway or question"""
68
+
69
+
70
+ SCRIPT_PROMPT_TEMPLATE = """USER'S NOTES AND BULLET POINTS:
71
+ {user_input}
72
+
73
+ JOHNNY'S STYLE REFERENCE (transcript excerpts from his videos):
74
+ {context}
75
+
76
+ INSTRUCTIONS:
77
+ Transform the user's notes into a YouTube Short script (under 3 minutes) in Johnny Harris's voice.
78
+
79
+ Requirements:
80
+ 1. HOOK IMMEDIATELY - first sentence must grab attention (no "hey guys" or slow intros)
81
+ 2. Keep it to ONE core idea - shorts don't have time for tangents
82
+ 3. Use Johnny's characteristic phrases and energy from the excerpts
83
+ 4. Punchier pacing - short sentences, quick reveals, maintain momentum
84
+ 5. End with a memorable line - a surprising fact, provocative question, or reframe
85
+ 6. Do not include any visual cues, bracketed notes, or stage directions—return only the spoken script text.
86
+
87
+ Target: 400-500 words (2-3 minutes when spoken at YouTube pace).
88
+ Write a script that sounds like Johnny but optimized for the short-form vertical format."""
89
+
90
+
91
+ # =============================================================================
92
+ # UTILITY CLASSES AND FUNCTIONS
93
+ # =============================================================================
94
+
95
+ class SimplePromptTemplate:
96
+ """Simple prompt template using string formatting"""
97
+
98
+ def __init__(self, template: str, input_variables: list):
99
+ self.template = template
100
+ self.input_variables = input_variables
101
+
102
+ def format(self, **kwargs) -> str:
103
+ """Format the template with provided variables"""
104
+ return self.template.format(**kwargs)
105
+
106
+
107
+ TOPIC_SEARCH_PROMPT = SimplePromptTemplate(
108
+ template=TOPIC_SEARCH_PROMPT_TEMPLATE,
109
+ input_variables=["query", "context"]
110
+ )
111
+
112
+ SCRIPT_PROMPT = SimplePromptTemplate(
113
+ template=SCRIPT_PROMPT_TEMPLATE,
114
+ input_variables=["user_input", "context"]
115
+ )
116
+
117
+
118
+ def get_topic_search_prompt() -> SimplePromptTemplate:
119
+ """Get the topic search prompt template"""
120
+ return TOPIC_SEARCH_PROMPT
121
+
122
+
123
+ def get_script_prompt() -> SimplePromptTemplate:
124
+ """Get the script generation prompt template"""
125
+ return SCRIPT_PROMPT
src/vectorstore.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase PGVector connection for Johnny Harris transcript embeddings"""
2
+
3
+ import os
4
+ import random
5
+ from typing import List, Dict, Any, Optional
6
+ from supabase import create_client, Client
7
+ import requests
8
+
9
+
10
+ class TranscriptChunk:
11
+ """Represents a transcript chunk from the database"""
12
+
13
+ def __init__(self, chunk_text: str, metadata: dict):
14
+ self.chunk_text = chunk_text
15
+ self.metadata = metadata
16
+
17
+ @property
18
+ def video_id(self) -> str:
19
+ return self.metadata.get('video_id', '')
20
+
21
+ @property
22
+ def video_url(self) -> str:
23
+ return self.metadata.get('video_url', '')
24
+
25
+ @property
26
+ def title(self) -> str:
27
+ return self.metadata.get('title', '')
28
+
29
+ @property
30
+ def chunk_index(self) -> int:
31
+ return self.metadata.get('chunk_index', 0)
32
+
33
+ @property
34
+ def total_chunks(self) -> int:
35
+ return self.metadata.get('total_chunks', 0)
36
+
37
+ @property
38
+ def similarity(self) -> float:
39
+ return self.metadata.get('similarity', 0.0)
40
+
41
+
42
+ class TranscriptVectorStore:
43
+ """Manages connection to Supabase PGVector database with Johnny Harris transcript embeddings"""
44
+
45
+ def __init__(
46
+ self,
47
+ supabase_url: Optional[str] = None,
48
+ supabase_key: Optional[str] = None,
49
+ jina_api_key: Optional[str] = None,
50
+ embedding_model: str = "jina-embeddings-v3"
51
+ ):
52
+ """
53
+ Initialize the vector store connection
54
+
55
+ Args:
56
+ supabase_url: Supabase project URL (defaults to SUPABASE_URL env var)
57
+ supabase_key: Supabase anon key (defaults to SUPABASE_KEY env var)
58
+ jina_api_key: Jina AI API key (defaults to JINA_API_KEY env var)
59
+ embedding_model: Embedding model to use (default: jina-embeddings-v3)
60
+ """
61
+ self.supabase_url = supabase_url or os.getenv("SUPABASE_URL")
62
+ self.supabase_key = supabase_key or os.getenv("SUPABASE_KEY")
63
+ self.jina_api_key = jina_api_key or os.getenv("JINA_API_KEY")
64
+ self.embedding_model = embedding_model
65
+
66
+ if not self.supabase_url or not self.supabase_key:
67
+ raise ValueError("SUPABASE_URL and SUPABASE_KEY environment variables must be set")
68
+
69
+ if not self.jina_api_key:
70
+ raise ValueError("JINA_API_KEY environment variable must be set")
71
+
72
+ # Initialize Supabase client
73
+ self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
74
+
75
+ def _generate_embedding(self, text: str, task: str = "retrieval.query") -> List[float]:
76
+ """
77
+ Generate embedding for text using Jina AI API
78
+
79
+ Args:
80
+ text: Text to embed
81
+ task: Task type - 'retrieval.query' for queries, 'retrieval.passage' for documents
82
+
83
+ Returns:
84
+ List of floats representing the embedding vector (1024 dimensions)
85
+ """
86
+ try:
87
+ api_url = "https://api.jina.ai/v1/embeddings"
88
+ headers = {
89
+ "Content-Type": "application/json",
90
+ "Authorization": f"Bearer {self.jina_api_key}"
91
+ }
92
+ payload = {
93
+ "model": self.embedding_model,
94
+ "task": task,
95
+ "input": [text]
96
+ }
97
+
98
+ response = requests.post(api_url, headers=headers, json=payload, timeout=30)
99
+
100
+ if response.status_code != 200:
101
+ raise Exception(f"Jina API returned status {response.status_code}: {response.text}")
102
+
103
+ result = response.json()
104
+
105
+ if isinstance(result, dict) and 'data' in result:
106
+ return result['data'][0]['embedding']
107
+
108
+ raise Exception("Unexpected response format from Jina API")
109
+
110
+ except Exception as e:
111
+ raise Exception(f"Error generating embedding: {str(e)}")
112
+
113
+ def similarity_search(
114
+ self,
115
+ query: str,
116
+ k: int = 10,
117
+ match_threshold: float = 0.7
118
+ ) -> List[TranscriptChunk]:
119
+ """
120
+ Perform similarity search on the transcript database (Tab 1: Topic Search)
121
+
122
+ Args:
123
+ query: Search query
124
+ k: Number of results to return
125
+ match_threshold: Minimum similarity threshold (0.0 to 1.0)
126
+
127
+ Returns:
128
+ List of TranscriptChunk objects with relevant transcript chunks
129
+ """
130
+ query_embedding = self._generate_embedding(query, task="retrieval.query")
131
+
132
+ try:
133
+ response = self.supabase.rpc(
134
+ 'match_transcripts',
135
+ {
136
+ 'query_embedding': query_embedding,
137
+ 'match_threshold': match_threshold,
138
+ 'match_count': k
139
+ }
140
+ ).execute()
141
+
142
+ chunks = []
143
+ for item in response.data:
144
+ chunk = TranscriptChunk(
145
+ chunk_text=item.get('chunk_text') or '',
146
+ metadata={
147
+ 'video_id': item.get('video_id'),
148
+ 'video_url': item.get('video_url'),
149
+ 'title': item.get('title', ''),
150
+ 'chunk_index': item.get('chunk_index'),
151
+ 'total_chunks': item.get('total_chunks'),
152
+ 'similarity': item.get('similarity', 0.0)
153
+ }
154
+ )
155
+ chunks.append(chunk)
156
+
157
+ return chunks
158
+
159
+ except Exception as e:
160
+ raise Exception(f"Error performing similarity search: {str(e)}")
161
+
162
+ def get_video_chunks(self, video_id: str) -> List[TranscriptChunk]:
163
+ """
164
+ Fetch all chunks for a specific video
165
+
166
+ Args:
167
+ video_id: YouTube video ID
168
+
169
+ Returns:
170
+ List of TranscriptChunk objects ordered by chunk_index
171
+ """
172
+ try:
173
+ response = self.supabase.from_('johnny_transcripts') \
174
+ .select('video_id, video_url, title, chunk_text, chunk_index, total_chunks') \
175
+ .eq('video_id', video_id) \
176
+ .order('chunk_index') \
177
+ .execute()
178
+
179
+ chunks = []
180
+ for item in response.data:
181
+ chunk = TranscriptChunk(
182
+ chunk_text=item.get('chunk_text') or '',
183
+ metadata={
184
+ 'video_id': item.get('video_id'),
185
+ 'video_url': item.get('video_url'),
186
+ 'title': item.get('title', ''),
187
+ 'chunk_index': item.get('chunk_index'),
188
+ 'total_chunks': item.get('total_chunks'),
189
+ 'similarity': 1.0
190
+ }
191
+ )
192
+ chunks.append(chunk)
193
+
194
+ return chunks
195
+
196
+ except Exception as e:
197
+ raise Exception(f"Error fetching video chunks: {str(e)}")
198
+
199
+ def get_random_diverse_chunks(self, n: int = 50) -> List[TranscriptChunk]:
200
+ """
201
+ Fetch random chunks from different videos for style variety
202
+
203
+ Args:
204
+ n: Number of random chunks to fetch
205
+
206
+ Returns:
207
+ List of TranscriptChunk objects from diverse videos
208
+ """
209
+ try:
210
+ # Get all unique video IDs first
211
+ response = self.supabase.from_('johnny_transcripts') \
212
+ .select('video_id') \
213
+ .execute()
214
+
215
+ video_ids = list(set(item['video_id'] for item in response.data if item.get('video_id')))
216
+
217
+ if not video_ids:
218
+ return []
219
+
220
+ # Sample from different videos to ensure diversity
221
+ chunks = []
222
+ chunks_per_video = max(1, n // len(video_ids)) if video_ids else n
223
+
224
+ # Shuffle video IDs for randomness
225
+ random.shuffle(video_ids)
226
+
227
+ for video_id in video_ids[:min(len(video_ids), n)]:
228
+ try:
229
+ # Get random chunks from this video
230
+ video_response = self.supabase.from_('johnny_transcripts') \
231
+ .select('video_id, video_url, title, chunk_text, chunk_index, total_chunks') \
232
+ .eq('video_id', video_id) \
233
+ .limit(chunks_per_video) \
234
+ .execute()
235
+
236
+ for item in video_response.data:
237
+ chunk = TranscriptChunk(
238
+ chunk_text=item.get('chunk_text') or '',
239
+ metadata={
240
+ 'video_id': item.get('video_id'),
241
+ 'video_url': item.get('video_url'),
242
+ 'title': item.get('title', ''),
243
+ 'chunk_index': item.get('chunk_index'),
244
+ 'total_chunks': item.get('total_chunks'),
245
+ 'similarity': 0.0 # Random selection, no similarity score
246
+ }
247
+ )
248
+ chunks.append(chunk)
249
+
250
+ if len(chunks) >= n:
251
+ break
252
+
253
+ except Exception:
254
+ continue
255
+
256
+ return chunks[:n]
257
+
258
+ except Exception as e:
259
+ raise Exception(f"Error fetching random chunks: {str(e)}")
260
+
261
+ def get_bulk_style_context(
262
+ self,
263
+ topic_query: str,
264
+ max_chunks: int = 100,
265
+ topic_relevant_ratio: float = 0.3
266
+ ) -> List[TranscriptChunk]:
267
+ """
268
+ Retrieve maximum context from knowledge base for script generation (Tab 2)
269
+
270
+ This method combines:
271
+ 1. Topic-relevant chunks (found via similarity search)
272
+ 2. Diverse random samples from across the archive
273
+
274
+ The entire knowledge base serves as the style reference.
275
+
276
+ Args:
277
+ topic_query: User's topic/bullet points to find relevant content
278
+ max_chunks: Maximum number of chunks to retrieve
279
+ topic_relevant_ratio: Ratio of chunks that should be topic-relevant (0.0 to 1.0)
280
+
281
+ Returns:
282
+ List of TranscriptChunk objects (topic-relevant + diverse samples)
283
+ """
284
+ topic_relevant_count = int(max_chunks * topic_relevant_ratio)
285
+ diverse_count = max_chunks - topic_relevant_count
286
+
287
+ # Get topic-relevant chunks
288
+ topic_chunks = self.similarity_search(
289
+ query=topic_query,
290
+ k=topic_relevant_count,
291
+ match_threshold=0.3 # Lower threshold to get more results
292
+ )
293
+
294
+ # Get diverse random chunks for style variety
295
+ diverse_chunks = self.get_random_diverse_chunks(n=diverse_count)
296
+
297
+ # Combine and deduplicate by video_id + chunk_index
298
+ seen = set()
299
+ combined = []
300
+
301
+ for chunk in topic_chunks + diverse_chunks:
302
+ key = (chunk.video_id, chunk.chunk_index)
303
+ if key not in seen:
304
+ seen.add(key)
305
+ combined.append(chunk)
306
+
307
+ return combined[:max_chunks]
308
+
309
+ def get_all_chunks(self, limit: int = 500) -> List[TranscriptChunk]:
310
+ """
311
+ Fetch all chunks from the database (up to limit)
312
+
313
+ Args:
314
+ limit: Maximum number of chunks to fetch
315
+
316
+ Returns:
317
+ List of TranscriptChunk objects
318
+ """
319
+ try:
320
+ response = self.supabase.from_('johnny_transcripts') \
321
+ .select('video_id, video_url, title, chunk_text, chunk_index, total_chunks') \
322
+ .limit(limit) \
323
+ .execute()
324
+
325
+ chunks = []
326
+ for item in response.data:
327
+ chunk = TranscriptChunk(
328
+ chunk_text=item.get('chunk_text') or '',
329
+ metadata={
330
+ 'video_id': item.get('video_id'),
331
+ 'video_url': item.get('video_url'),
332
+ 'title': item.get('title', ''),
333
+ 'chunk_index': item.get('chunk_index'),
334
+ 'total_chunks': item.get('total_chunks'),
335
+ 'similarity': 0.0
336
+ }
337
+ )
338
+ chunks.append(chunk)
339
+
340
+ return chunks
341
+
342
+ except Exception as e:
343
+ raise Exception(f"Error fetching all chunks: {str(e)}")
344
+
345
+ def format_results_for_display(self, chunks: List[TranscriptChunk]) -> str:
346
+ """
347
+ Format search results for Tab 1 display
348
+
349
+ Args:
350
+ chunks: List of TranscriptChunk objects
351
+
352
+ Returns:
353
+ Formatted markdown string for display
354
+ """
355
+ if not chunks:
356
+ return "No matching content found."
357
+
358
+ # Group by video
359
+ videos = {}
360
+ for chunk in chunks:
361
+ video_id = chunk.video_id
362
+ if video_id not in videos:
363
+ videos[video_id] = {
364
+ 'title': chunk.title,
365
+ 'url': chunk.video_url,
366
+ 'chunks': [],
367
+ 'max_similarity': 0.0
368
+ }
369
+ videos[video_id]['chunks'].append(chunk)
370
+ videos[video_id]['max_similarity'] = max(
371
+ videos[video_id]['max_similarity'],
372
+ chunk.similarity
373
+ )
374
+
375
+ # Sort by max similarity
376
+ sorted_videos = sorted(
377
+ videos.items(),
378
+ key=lambda x: x[1]['max_similarity'],
379
+ reverse=True
380
+ )
381
+
382
+ # Format output
383
+ output = []
384
+ for video_id, data in sorted_videos:
385
+ similarity_pct = int(data['max_similarity'] * 100)
386
+ output.append(f"### [{data['title']}]({data['url']})")
387
+ output.append(f"**Relevance:** {similarity_pct}%\n")
388
+
389
+ # Show top excerpt
390
+ top_chunk = max(data['chunks'], key=lambda c: c.similarity)
391
+ excerpt = top_chunk.chunk_text[:500] + "..." if len(top_chunk.chunk_text) > 500 else top_chunk.chunk_text
392
+ output.append(f"> {excerpt}\n")
393
+
394
+ return "\n".join(output)
395
+
396
+ def format_context_for_llm(self, chunks: List[TranscriptChunk]) -> str:
397
+ """
398
+ Format chunks as context for LLM script generation (Tab 2)
399
+
400
+ Args:
401
+ chunks: List of TranscriptChunk objects
402
+
403
+ Returns:
404
+ Formatted string with transcript excerpts for LLM context
405
+ """
406
+ if not chunks:
407
+ return ""
408
+
409
+ formatted = []
410
+ for i, chunk in enumerate(chunks, 1):
411
+ formatted.append(f"[Excerpt {i} - {chunk.title}]\n{chunk.chunk_text}")
412
+
413
+ return "\n\n---\n\n".join(formatted)
414
+
415
+
416
+ def create_vectorstore() -> TranscriptVectorStore:
417
+ """Factory function to create and return a configured vector store"""
418
+ return TranscriptVectorStore()