crystaljhollis's picture
Proof of Concept
99efcd0 verified
Raw History Blame
24.7 kB
# Main.py by Crystal J. Hollis | crystaljhollis@gmail.com
# GitHub: https://github.com/crystaljhollis
# 08/2025
# Developed using Python 3.13.7
#
# =======================================================================================================================================
# LIBRARIES
# =======================================================================================================================================
# Import Python Standard Libraries
import re #regular expressions aka regex
from pathlib import Path #file system paths
from typing import List, Tuple, Set #gradual typing support
# Import gradio
import gradio as gr # Documentation: https://www.gradio.app/
# =======================================================================================================================================
# FUNCTIONS
# =======================================================================================================================================
# HELPER FUNCTIONS
_SLUG_OK = re.compile(r"[^a-z0-9_]+")
def slugify(text: str) -> str:
"""lowercase, spaces->underscore, drop junk"""
s = text.strip().lower()
s = s.replace("&", " and ")
s = re.sub(r"[^\w\s-]", "", s) # keep letters/numbers/underscore/hyphen/space
s = re.sub(r"\s+", "_", s) # collapse spaces to _
s = _SLUG_OK.sub("_", s) # anything else -> _
s = re.sub(r"_+", "_", s).strip("_") # collapse repeats
return s
def suggest_slugs(locked_people: List[str], locked_locs: List[str], rough_terms: List[str]) -> List[str]:
"""
Very simple slug ideas using location + common event words.
Swap this for an LLM later if you want.
"""
loc = locked_locs[0] if locked_locs else ""
base = slugify(loc) if loc else "gallery_item"
# pick a few common event-ish tags if present
vocab = ["opening ceremony", "ribbon cutting", "audience", "ceremony", "speeches", "panel", "performance"]
hits = [v for v in vocab if any(v in t.lower() for t in rough_terms)]
slugs = [f"{base}_{slugify(h)}" for h in hits[:3]] or [base]
# add a couple safe fallbacks
return list(dict.fromkeys(slugs + [f"{base}_event", f"{base}_scene"]))
def suggest_headline_title(locked_locs: List[str], rough_terms: List[str]) -> Tuple[str, str]:
"""
Headline = longer, Title = shorter. Heuristics only.
"""
loc = locked_locs[0] if locked_locs else "Campus"
if any("ribbon" in t.lower() and "cut" in t.lower() for t in rough_terms):
headline = f"{loc} Opening Ceremony and Ribbon Cutting at SMU"
title = f"{loc} Opening Ceremony"
else:
headline = f"Event at {loc}, Southern Methodist University"
title = f"{loc} Event"
return headline, title
def suggest_description(locked_locs: List[str], rough_terms: List[str]) -> str:
"""
Template now; replace with Gemma call later.
"""
loc = locked_locs[0] if locked_locs else "campus venue"
phrases = [t for t in rough_terms if len(t.split()) <= 4][:6]
core = ", ".join(phrases) if phrases else "program footage, speeches, audience"
return (
f"Program footage from the {loc} event at Southern Methodist University, featuring {core} "
f"in Dallas, Texas."
)
def people_variants_stub(people_list: List[str]) -> str:
"""
Placeholder until Gemma is wired. Produces generic roles.
"""
if not people_list:
return ""
base = []
# naive role guessers
hints = ["president", "dean", "trustee", "donor", "student", "faculty"]
base.extend({h for h in hints})
return ", ".join(sorted(set(base)))
# SPLIT TERMS FUNCTION == accepts text and returns a cleaned list
def _split_terms(text: str) -> List[str]:
"""
Accepts comma/newline/semicolon separated text and returns a cleaned list.
- trims spaces
- collapses internal whitespace
- removes surrounding quotes
- keeps hyphens and apostrophes (useful for names/places)
"""
if not text:
return [] # Empty text returns empty list
parts = re.split(r"[,\n;]+", text) # regex split on commas, semicolons, or newlines
cleaned = [] # Prepare list to hold cleaned-up terms
for p in parts: # Iterates through each text piece
t = p.strip().strip('"').strip("'") # removes trailing spaces, double/single quotes
# collapse extra internal spaces
t = re.sub(r"\s+", " ", t) # regex sub whitespace w/single space (\s+)
if t:
cleaned.append(t) # If t is not empty, append to the cleaned list
return cleaned # Returns the cleaned list
# LOAD MASTER KEYWORDS FUNCTION == user uploads txt file
def _load_master_keywords(file_obj) -> Set[str]: # Accepts .txt, returns lowercase keywords
"""
Load hierarchical SMU keyword file.
- Keeps each line as its own entry
- Ignores bracketed category headers like [Academic Life]
- Strips tabs/spaces
"""
if not file_obj:
return set()
if isinstance(file_obj, bytes):
content = file_obj.decode("utf-8", errors="ignore") # Decode file_obj into string with UTF-8 encoding, ignore weird characters
else:
content = str(file_obj) #force to string
keywords = set()
for line in content.splitlines(): # Iterates line by line
line = line.strip()
if not line:
continue # Ignores blank lines
# skip category headers in [brackets]
if line.startswith("[") and line.endswith("]"):
continue # Ignores [Category] headers
keywords.add(line.lower()) # Preserve keywords, matching lowercase
return keywords # Returns the cleaned set of keywords
# COMMA+SPACE FUNCTION == simply adds comma and space for each keyword
def _to_csv_line(items: List[str]) -> str: # Adds comma and space to cleaned list
"""
Make a comma+space separated line
for copy-paste.
"""
return ", ".join(items) # Concatenates all elements separated by ", "
# KEYWORD CHECKER FUNCTION
def keyword_checker( # Takes arguments:
job_number: str, # Job Number i.e. 26-999
neg_number: str, # Neg Number
rough_keywords: str, # Rough draft keywords
people_locked: str, # Names of Persons Shown
locations_locked: str, # Names of Locations
names_locked: str, # Other locked terms
master_txt # Latest keyword txt file
):
# [1] Parse inputs; Converts raw text into a list, calling _split_terms
rough_list = _split_terms(rough_keywords)
people_list = _split_terms(people_locked)
locations_list = _split_terms(locations_locked)
names_list = _split_terms(names_locked)
# [2] Build locked sets (lowercase for matching)
locked_map = { # Dictionary
"PEOPLE": set(p.lower() for p in people_list), # lowercase so matching is case-insensitive
"LOCATIONS": set(l.lower() for l in locations_list),
"LOCKED": set(a.lower() for a in names_list),
}
locked_all = set().union(*locked_map.values()) # Combines everything in the dictionary, keeping only unique elements
# [3] Load master keyword list
master_set = _load_master_keywords(master_txt) if master_txt else set() # Load keyword txt if provided, else empty set
# [4] Categorize
locked_hits = {"PEOPLE": [], "LOCATIONS": [], "LOCKED": []} # locked terms separated by category
in_master = [] # from keyword txt file
new_terms = [] # not in keyword txt file
for kw in rough_list:
k = kw.lower() # loop through each keywords, make it lowercase
# Check locked first (so they can be handled specially later)
hit_locked = False # checked if keyword is in a locked category
for cat, s in locked_map.items():
if k in s:
locked_hits[cat].append(kw) # If yes, add to locked_hits
hit_locked = True
break
if hit_locked: # locked terms don't need to be checked against keyword txt file
continue
# Then check against master
if master_set: # check if in the keyword txt file
if k in master_set:
in_master.append(kw) # If yes, in_master
else:
new_terms.append(kw) # If no, new_terms
else:
# No master list provided
in_master.append(kw) # If no keyword txt file, treat all non-locked terms as in_master
# [5] Build human-readable outputs
locked_block = {
"PEOPLE": _to_csv_line(locked_hits["PEOPLE"]),
"LOCATIONS": _to_csv_line(locked_hits["LOCATIONS"]),
"LOCKED": _to_csv_line(locked_hits["LOCKED"]),
}
in_list_block = _to_csv_line(in_master)
new_block = _to_csv_line(new_terms)
combined_csv = _to_csv_line(locked_hits["PEOPLE"] + locked_hits["LOCATIONS"] + locked_hits["LOCKED"] + in_master + new_terms) # Combined summary output
# [6] LLM customizable fields
slugs = suggest_slugs(locked_hits["PEOPLE"], locked_hits["LOCATIONS"], rough_list)
headline, title = suggest_headline_title(locked_hits["LOCATIONS"], rough_list)
description = suggest_description(locked_hits["LOCATIONS"], rough_list)
people_variants = people_variants_stub(locked_hits["PEOPLE"]) # replace w/ Gemma later
# [7] Summary line
summary = (
f"Job {job_number or '(none)'} | " # Job Number
f"Catalog {neg_number or '(none)'} | " # Neg Number
f"Total input: {len(rough_list)} | " # Total Keywords Checked
f"Locked: {sum(len(v) for v in locked_hits.values())} | " # Total Locked Hits Category
f"In List: {len(in_master)} | " # Total from keyword txt file
f"New: {len(new_terms)}" # Total new keywords
)
# [8] Return in same order as UI outputs
return (
job_number, # Job Number (echo)
neg_number, # Neg Number (echo)
"\n".join(slugs), # Recommended filename slugs
headline, # Headline
title, # Title
description, # Description / Alt Text
locked_block["PEOPLE"], # PEOPLE (Persons Shown)
people_variants, # PEOPLE VARIANTS (suggested / LLM)
locked_block["LOCATIONS"], # LOCKED: LOCATIONS
locked_block["LOCKED"], # LOCKED: OTHER
in_list_block, # BATCH + SEO
new_block, # OPTIONAL + SEO
combined_csv, # All Combined CSV
"", # Credit Line Field (optional inputs)
"", # Creator Field (optional inputs)
summary, # (optional) Summary – nice to have last
)
# =======================================================================================================================================
# GRADIO GUI CONFIG
# =======================================================================================================================================
# Theme Object
custom = gr.themes.Base(
primary_hue="red", # Red main accent color family
neutral_hue="zinc", # Neutral palette
).set( # Override CSS
# Page text
body_text_color="#1c1c1c", # black text
body_text_color_subdued="#666666", # subdued text dark gray
body_text_size="14px", # Font size
body_text_weight="400", # regular weight font
# Page & blocks
background_fill_primary="#ffffff", # white cards
background_fill_secondary="#ffffff", # dark mode, white cards
block_background_fill="#eeeeee", # light gray background
block_border_color="#d6d6d6", # light gray border
block_title_text_color="#1c1c1c", # black text block titles
# Inputs/buttons
input_background_fill="#ffffff", # white input boxes
input_border_color="#d6d6d6", # light gray border
button_primary_background_fill="#ce191c", # red primary buttons
button_primary_background_fill_hover="#7b2325", # dark red on hover
button_primary_text_color="#ffffff", # white text on red button
button_secondary_background_fill="#343434", # dark mode, very dark gray
button_secondary_background_fill_hover="#2b2b2b", # dark mode, darker gray on hover
button_secondary_text_color="#ffffff", # dark mode, white text
# Links + radius/shadow
link_text_color="#1c1c1c", # link text is black
embed_radius="8px", # all cards/blocks have slightly rounded corners
border_color_accent="#d6d6d6", # light gray accent borders
shadow_drop="0 4px 16px rgba(0,0,0,0.08)", # subtle shadow effect around cards
)
CSS = '''
/* ====== Palette - unused at the moment, save for later ====== */
:root{
--smu-dark-gray: #666666;
--smu-very-dark: #343434;
--smu-light-gray: #d6d6d6;
--smu-off-white: #eeeeee;
--smu-white: #ffffff;
--smu-black: #1c1c1c;
--smu-red: #8e2a2c;
--smu-salmon: #ff756d; /* reserved for "suggested keywords" – not used yet */
}
/* ====== Hard-lock app to light mode ====== */
html, :root, body, .gradio-container{
color-scheme: light !important; /* browsers render controls as light */
background: var(--smu-off-white) !important; /* page background */
color: var(--smu-black) !important;
}
/* ====== Global text ====== */
h1, h2, h3, label, p, .gr-markdown *{
color: var(--smu-black) !important;
}
/* ====== Panels / cards ====== */
.gr-block{
background: var(--smu-white) !important;
border: 1px solid var(--smu-light-gray) !important;
border-radius: 8px !important;
}
/* ====== Tabs / top bars (FileMaker-ish chrome) ====== */
.gr-tabs, .gr-panel{ background: var(--smu-dark-gray) !important; }
.gr-tabs *, .gr-panel *{ color: var(--smu-white) !important; }
/* ====== Inputs ====== */
.gradio-container input[type="text"],
.gradio-container textarea{
background: var(--smu-white) !important;
color: var(--smu-black) !important;
border-color: var(--smu-light-gray) !important;
}
.gradio-container input[type="text"]::placeholder,
.gradio-container textarea::placeholder{
color: var(--smu-dark-gray) !important;
opacity: 1 !important;
}
/* ====== Buttons ====== */
button.gr-button-primary{
background: var(--smu-red) !important; color: #fff !important;
}
button.gr-button-primary:hover{ filter: brightness(.92); }
button.gr-button-secondary{
background: var(--smu-very-dark) !important; color: #fff !important;
}
button.gr-button-secondary:hover{ filter: brightness(1.05); }
/* ====== Copy blocks (make borders more visible) ====== */
/* Add elem_classes=["copyblock"] to those Textboxes you want highlighted */
.copyblock textarea{
border: 2px solid var(--smu-dark-gray) !important;
box-shadow: inset 0 1px 0 rgba(0,0,0,.03);
}
/* ====== Upload zones / dividers ====== */
#upload_panel, #upload_group{ background: var(--smu-white) !important; border-radius: 8px; }
input[type="file"], .gr-file{ border-color: var(--smu-light-gray) !important; }
hr{ border-color: var(--smu-light-gray) !important; }
/* ====== Reserved style for future "suggested keywords" (not applied yet) ====== */
/* Later you can add elem_classes=["suggested"] to a Textbox or container */
.suggested textarea, .suggested{
/* NOT enabled yet; uncomment when wiring the feature:
background: #ff756d1a !important; /* subtle salmon tint */
border-color: var(--smu-salmon) !important;
*/
}
'''
# =======================================================================================================================================
# GRADIO APP
# =======================================================================================================================================
with gr.Blocks(title="Job Sheet Keyword and Metadata SEO", theme=custom) as demo: # css=CSS is not being used at the moment! # gr.Blocks is a container laying out multiple UI elements
gr.Markdown( # gr.Markdown block to explain to the user what the app does
"# Job Sheet Keyword and Metadata SEO \n"
"Paste rough draft keywords, add optional **locked terms** (PEOPLE / LOCATIONS / NAMES), "
"and upload the **latest keyword file** (.txt)."
)
with gr.Row(): # gr.Row horizontal components
job_number = gr.Textbox(label="Job Number", placeholder="i.e. 26-999", scale=1) # Job Number Textbox
neg_number = gr.Textbox(label="Catalog / Neg Number", placeholder="i.e. 12345D", scale=1) # Neg Number Textbox
rough = gr.Textbox( # Rough Draft Keywords Textbox
label="Rough Draft Keywords (comma / newline / semicolon separated)",
lines=6,
placeholder="i.e. Owen Arts Center, Meadows School of the Arts, SMU, opening ceremony, ribbon cutting, Dallas, Texas"
)
with gr.Accordion("Locked Terms (will not generate SEO suggestions / synonyms)", open=False, elem_id="locked_panel"): # Dropdown Section Locked Terms
with gr.Row():
people = gr.Textbox(label="PEOPLE (Persons Shown)", lines=4, placeholder="i.e. R. Gerald Turner, Samuel S. Holland") # Persons Shown Textbox
locations = gr.Textbox(label="SUBLOCATION or ADDRESS / CITY / STATE / COUNTRY", lines=4, placeholder="i.e. Owen Arts Center, Dallas, Texas, United States") # Locations Textbox
names = gr.Textbox(label="OTHER LOCKED TERMS", lines=4, placeholder="i.e. Meadows School of the Arts, Lyle School of Engineering") # Locked Terms Textbox
with gr.Row():
credit_in = gr.Textbox(label="Credit Line (optional input)", placeholder="Southern Methodist University / John Doe", scale=1)
creator_in = gr.Textbox(label="Creator (optional input)", placeholder="John Doe", scale=1)
master = gr.File(label="Upload the latest keyword list (.txt Keyword file; newline or comma separated)", # Upload dialog
file_types=[".txt"], elem_id="upload_panel")
run = gr.Button("Run Keyword Optimizer", variant="primary") # Run Red Button
# ---- OUTPUTS (order must match function returns) ----
job_out = gr.Textbox(label="Job Number", interactive=False, lines=1, elem_classes=["copyblock"])
neg_out = gr.Textbox(label="Neg Number (Job Identifier Field)", interactive=False, lines=1, elem_classes=["copyblock"])
slugs_out = gr.Textbox(label="Recommended filename slugs (suggested)", interactive=False, lines=4, elem_classes=["copyblock"])
headline_out = gr.Textbox(label="Headline", interactive=False, lines=2, elem_classes=["copyblock"])
title_out = gr.Textbox(label="Title", interactive=False, lines=2, elem_classes=["copyblock"])
desc_out = gr.Textbox(label="Description / Alt Text", interactive=False, lines=4, elem_classes=["copyblock"])
people_out = gr.Textbox(label="PEOPLE (Persons Shown Field)", interactive=False, lines=3, elem_classes=["copyblock"])
peoplevar_out = gr.Textbox(label="PEOPLE VARIANTS (suggested)", interactive=False, lines=2, elem_classes=["copyblock"])
loc_out = gr.Textbox(label="LOCKED: LOCATIONS (copy block)", interactive=False, lines=2, elem_classes=["copyblock"])
locked_out = gr.Textbox(label="LOCKED TERMS (no synonyms)", interactive=False, lines=2, elem_classes=["copyblock"])
in_list_out = gr.Textbox(label="BATCH + SEO (suggested)", interactive=False, lines=4, elem_classes=["copyblock"])
new_out = gr.Textbox(label="OPTIONAL + SEO (new)", interactive=False, lines=4, elem_classes=["copyblock"])
csv_out = gr.Textbox(label="All Combined (CSV)", interactive=False, lines=3, elem_classes=["copyblock"])
credit_out = gr.Textbox(label="Credit Line Field", interactive=False, lines=1, elem_classes=["copyblock"])
creator_out = gr.Textbox(label="Creator Field", interactive=False, lines=1, elem_classes=["copyblock"])
summary_out = gr.Textbox(label="Summary (debug)", interactive=False, lines=2)
# Click wiring – note input list includes credit/creator, pass them into function if you want to use them
run.click(
fn=keyword_checker,
inputs=[job_number, neg_number, rough, people, locations, names, master],
outputs=[job_out, neg_out, slugs_out, headline_out, title_out, desc_out,
people_out, peoplevar_out, loc_out, locked_out,
in_list_out, new_out, csv_out, credit_out, creator_out, summary_out]
)
#demo.launch(share=True) # launches the app, switch to false to turn off sharing
if __name__ == "__main__":
demo.launch() # local runs only