Spaces:
Sleeping
Sleeping
Upload 47 files
Browse files- .gitignore +207 -0
- LICENSE +21 -0
- Marketeer_Patched_Video.ipynb +689 -0
- README.md +1 -12
- app.py +13 -0
- blueprint.md +340 -0
- check.ipynb +70 -0
- core_logic/__init__.py +0 -0
- core_logic/__pycache__/__init__.cpython-310.pyc +0 -0
- core_logic/__pycache__/chat_agent.cpython-310.pyc +0 -0
- core_logic/__pycache__/chat_chain.cpython-310.pyc +0 -0
- core_logic/__pycache__/copy_pipeline.cpython-310.pyc +0 -0
- core_logic/__pycache__/langchain_llm.cpython-310.pyc +0 -0
- core_logic/__pycache__/llm_client.cpython-310.pyc +0 -0
- core_logic/__pycache__/llm_config.cpython-310.pyc +0 -0
- core_logic/__pycache__/rewrite_tools.cpython-310.pyc +0 -0
- core_logic/__pycache__/video_pipeline.cpython-310.pyc +0 -0
- core_logic/__pycache__/video_schema.cpython-310.pyc +0 -0
- core_logic/chat_agent.py +216 -0
- core_logic/chat_chain.py +170 -0
- core_logic/copy_pipeline.py +114 -0
- core_logic/langchain_llm.py +45 -0
- core_logic/llm_client.py +120 -0
- core_logic/llm_config.py +50 -0
- core_logic/rewrite_tools.py +40 -0
- core_logic/video_pipeline.py +271 -0
- core_logic/video_schema.py +94 -0
- helpers/__init__.py +0 -0
- helpers/__pycache__/__init__.cpython-310.pyc +0 -0
- helpers/__pycache__/blueprints.cpython-310.pyc +0 -0
- helpers/__pycache__/json_utils.cpython-310.pyc +0 -0
- helpers/__pycache__/platform_rules.cpython-310.pyc +0 -0
- helpers/__pycache__/platform_styles.cpython-310.pyc +0 -0
- helpers/__pycache__/validators.cpython-310.pyc +0 -0
- helpers/blueprints.py +161 -0
- helpers/json_utils.py +72 -0
- helpers/platform_rules.py +149 -0
- helpers/platform_styles.py +111 -0
- helpers/validators.py +88 -0
- requirements.txt +17 -0
- test_llm_backend.py +29 -0
- todo.md +3 -0
- ui/__init__.py +0 -0
- ui/__pycache__/__init__.cpython-310.pyc +0 -0
- ui/__pycache__/gradio_ui.cpython-310.pyc +0 -0
- ui/gradio_ui.py +583 -0
- ui/gradio_ui_1.py +323 -0
.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py.cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
#poetry.toml
|
| 110 |
+
|
| 111 |
+
# pdm
|
| 112 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 113 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 114 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 115 |
+
#pdm.lock
|
| 116 |
+
#pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# pixi
|
| 121 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 122 |
+
#pixi.lock
|
| 123 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 124 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 125 |
+
.pixi
|
| 126 |
+
|
| 127 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 128 |
+
__pypackages__/
|
| 129 |
+
|
| 130 |
+
# Celery stuff
|
| 131 |
+
celerybeat-schedule
|
| 132 |
+
celerybeat.pid
|
| 133 |
+
|
| 134 |
+
# SageMath parsed files
|
| 135 |
+
*.sage.py
|
| 136 |
+
|
| 137 |
+
# Environments
|
| 138 |
+
.env
|
| 139 |
+
.envrc
|
| 140 |
+
.venv
|
| 141 |
+
env/
|
| 142 |
+
venv/
|
| 143 |
+
ENV/
|
| 144 |
+
env.bak/
|
| 145 |
+
venv.bak/
|
| 146 |
+
|
| 147 |
+
# Spyder project settings
|
| 148 |
+
.spyderproject
|
| 149 |
+
.spyproject
|
| 150 |
+
|
| 151 |
+
# Rope project settings
|
| 152 |
+
.ropeproject
|
| 153 |
+
|
| 154 |
+
# mkdocs documentation
|
| 155 |
+
/site
|
| 156 |
+
|
| 157 |
+
# mypy
|
| 158 |
+
.mypy_cache/
|
| 159 |
+
.dmypy.json
|
| 160 |
+
dmypy.json
|
| 161 |
+
|
| 162 |
+
# Pyre type checker
|
| 163 |
+
.pyre/
|
| 164 |
+
|
| 165 |
+
# pytype static type analyzer
|
| 166 |
+
.pytype/
|
| 167 |
+
|
| 168 |
+
# Cython debug symbols
|
| 169 |
+
cython_debug/
|
| 170 |
+
|
| 171 |
+
# PyCharm
|
| 172 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 173 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 174 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 175 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 176 |
+
#.idea/
|
| 177 |
+
|
| 178 |
+
# Abstra
|
| 179 |
+
# Abstra is an AI-powered process automation framework.
|
| 180 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 181 |
+
# Learn more at https://abstra.io/docs
|
| 182 |
+
.abstra/
|
| 183 |
+
|
| 184 |
+
# Visual Studio Code
|
| 185 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 186 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 187 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 188 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 189 |
+
# .vscode/
|
| 190 |
+
|
| 191 |
+
# Ruff stuff:
|
| 192 |
+
.ruff_cache/
|
| 193 |
+
|
| 194 |
+
# PyPI configuration file
|
| 195 |
+
.pypirc
|
| 196 |
+
|
| 197 |
+
# Cursor
|
| 198 |
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
| 199 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 200 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 201 |
+
.cursorignore
|
| 202 |
+
.cursorindexingignore
|
| 203 |
+
|
| 204 |
+
# Marimo
|
| 205 |
+
marimo/_static/
|
| 206 |
+
marimo/_lsp/
|
| 207 |
+
__marimo__/
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Katakam Prashanth
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Marketeer_Patched_Video.ipynb
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# 🧠 Marketeer — Conversational Marketing Bot (Patched)\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"**This version fixes video scripting returning empty content** by:\n",
|
| 10 |
+
"- Using Gemma-friendly prompting (no `system` role for JSON blocks)\n",
|
| 11 |
+
"- Strong JSON extraction + graceful fallback per beat\n",
|
| 12 |
+
"- REPL `/video` is fully wired to `make_video()`\n",
|
| 13 |
+
"- Includes a quick self‑test cell\n"
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"cell_type": "code",
|
| 18 |
+
"execution_count": 12,
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"outputs": [
|
| 21 |
+
{
|
| 22 |
+
"name": "stdout",
|
| 23 |
+
"output_type": "stream",
|
| 24 |
+
"text": [
|
| 25 |
+
"▶ Python: 3.10.11\n",
|
| 26 |
+
"▶ Platform: Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39\n",
|
| 27 |
+
"\n",
|
| 28 |
+
"▶ nvidia-smi:\n",
|
| 29 |
+
"Thu Nov 13 00:51:03 2025 \n",
|
| 30 |
+
"+-----------------------------------------------------------------------------------------+\n",
|
| 31 |
+
"| NVIDIA-SMI 560.35.03 Driver Version: 561.09 CUDA Version: 12.6 |\n",
|
| 32 |
+
"|-----------------------------------------+------------------------+----------------------+\n",
|
| 33 |
+
"| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |\n",
|
| 34 |
+
"| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |\n",
|
| 35 |
+
"| | | MIG M. |\n",
|
| 36 |
+
"|=========================================+========================+======================|\n",
|
| 37 |
+
"| 0 NVIDIA GeForce RTX 3060 ... On | 00000000:01:00.0 On | N/A |\n",
|
| 38 |
+
"| N/A 57C P8 13W / 85W | 4961MiB / 6144MiB | 13% Default |\n",
|
| 39 |
+
"| | | N/A |\n",
|
| 40 |
+
"+-----------------------------------------+------------------------+----------------------+\n",
|
| 41 |
+
" \n",
|
| 42 |
+
"+-----------------------------------------------------------------------------------------+\n",
|
| 43 |
+
"| Processes: |\n",
|
| 44 |
+
"| GPU GI CI PID Type Process name GPU Memory |\n",
|
| 45 |
+
"| ID ID Usage |\n",
|
| 46 |
+
"|=========================================================================================|\n",
|
| 47 |
+
"| 0 N/A N/A 6286 C /python3.10 N/A |\n",
|
| 48 |
+
"+-----------------------------------------------------------------------------------------+\n",
|
| 49 |
+
"\n"
|
| 50 |
+
]
|
| 51 |
+
}
|
| 52 |
+
],
|
| 53 |
+
"source": [
|
| 54 |
+
"import sys, subprocess, platform\n",
|
| 55 |
+
"print(f'▶ Python: {sys.version.split()[0]}')\n",
|
| 56 |
+
"print(f'▶ Platform: {platform.platform()}')\n",
|
| 57 |
+
"print('\\n▶ nvidia-smi:')\n",
|
| 58 |
+
"try:\n",
|
| 59 |
+
" print(subprocess.check_output(['nvidia-smi'], text=True))\n",
|
| 60 |
+
"except Exception:\n",
|
| 61 |
+
" print('(no GPU visible)')\n"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"cell_type": "markdown",
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"source": [
|
| 68 |
+
"## Install base libs\n",
|
| 69 |
+
"(If already installed, this is a no-op.)"
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"cell_type": "code",
|
| 74 |
+
"execution_count": null,
|
| 75 |
+
"metadata": {},
|
| 76 |
+
"outputs": [],
|
| 77 |
+
"source": [
|
| 78 |
+
"# %pip -q install --upgrade pip\n",
|
| 79 |
+
"# %pip -q install transformers accelerate sentence-transformers faiss-cpu pypdf textstat regex tiktoken rich"
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"cell_type": "code",
|
| 84 |
+
"execution_count": null,
|
| 85 |
+
"metadata": {},
|
| 86 |
+
"outputs": [],
|
| 87 |
+
"source": [
|
| 88 |
+
"import torch, transformers, textwrap, re, json\n",
|
| 89 |
+
"from transformers import AutoTokenizer, AutoModelForCausalLM\n",
|
| 90 |
+
"from typing import List, Dict, Any\n",
|
| 91 |
+
"from collections import deque\n",
|
| 92 |
+
"print({'torch': torch.__version__, 'transformers': transformers.__version__})"
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"cell_type": "code",
|
| 97 |
+
"execution_count": null,
|
| 98 |
+
"metadata": {},
|
| 99 |
+
"outputs": [],
|
| 100 |
+
"source": [
|
| 101 |
+
"from typing import Dict, List\n",
|
| 102 |
+
"import re\n",
|
| 103 |
+
"\n",
|
| 104 |
+
"PLATFORM_RULES: Dict[str, Dict[str, int]] = {\n",
|
| 105 |
+
" 'Instagram': {'cap': 2200, 'hashtags_max': 5, 'emoji_max': 5},\n",
|
| 106 |
+
" 'Facebook': {'cap': 125, 'hashtags_max': 0, 'emoji_max': 1},\n",
|
| 107 |
+
" 'LinkedIn': {'cap': 3000, 'hashtags_max': 3, 'emoji_max': 2},\n",
|
| 108 |
+
" 'Google Ads': {'cap': 90, 'hashtags_max': 0, 'emoji_max': 0},\n",
|
| 109 |
+
" 'Twitter/X': {'cap': 280, 'hashtags_max': 2, 'emoji_max': 2},\n",
|
| 110 |
+
"}\n",
|
| 111 |
+
"\n",
|
| 112 |
+
"BANNED_MAP = {\n",
|
| 113 |
+
" r'\\bguarantee(d|s)?\\b': 'aim to',\n",
|
| 114 |
+
" r'\\bno[-\\s]?risk\\b': 'low risk',\n",
|
| 115 |
+
" r'\\bno[-\\s]?questions[-\\s]?asked\\b': 'hassle-free',\n",
|
| 116 |
+
" r'\\b#?1\\b': 'top-rated',\n",
|
| 117 |
+
" r'\\bbest\\b': 'trusted',\n",
|
| 118 |
+
" r'\\bfastest\\b': 'fast',\n",
|
| 119 |
+
"}\n",
|
| 120 |
+
"EMOJI_RX = re.compile(r'[\\U0001F300-\\U0001FAFF\\U00002700-\\U000027BF]')\n",
|
| 121 |
+
"HASHTAG_RX = re.compile(r'(#\\w+)')\n",
|
| 122 |
+
"SPACE_RX = re.compile(r'\\s+')\n",
|
| 123 |
+
"\n",
|
| 124 |
+
"def _replace_banned(text: str, audit: List[str]) -> str:\n",
|
| 125 |
+
" new = text\n",
|
| 126 |
+
" for pat, repl in BANNED_MAP.items():\n",
|
| 127 |
+
" if re.search(pat, new, flags=re.I):\n",
|
| 128 |
+
" new = re.sub(pat, repl, new, flags=re.I)\n",
|
| 129 |
+
" audit.append(f\"Replaced banned phrasing '{pat}' -> '{repl}'.\")\n",
|
| 130 |
+
" return new\n",
|
| 131 |
+
"\n",
|
| 132 |
+
"def _limit_hashtags(s: str, max_tags: int, audit: List[str]) -> str:\n",
|
| 133 |
+
" tags = HASHTAG_RX.findall(s)\n",
|
| 134 |
+
" if max_tags == 0 and tags:\n",
|
| 135 |
+
" s2 = HASHTAG_RX.sub('', s)\n",
|
| 136 |
+
" s2 = SPACE_RX.sub(' ', s2).strip()\n",
|
| 137 |
+
" audit.append('Removed all hashtags per platform rules.')\n",
|
| 138 |
+
" return s2\n",
|
| 139 |
+
" if len(tags) <= max_tags:\n",
|
| 140 |
+
" return s\n",
|
| 141 |
+
" count = 0\n",
|
| 142 |
+
" toks = s.split()\n",
|
| 143 |
+
" for i, tok in enumerate(toks):\n",
|
| 144 |
+
" if tok.startswith('#'):\n",
|
| 145 |
+
" count += 1\n",
|
| 146 |
+
" if count > max_tags:\n",
|
| 147 |
+
" toks[i] = ''\n",
|
| 148 |
+
" s2 = ' '.join(t for t in toks if t)\n",
|
| 149 |
+
" audit.append(f'Trimmed hashtags to <= {max_tags}.')\n",
|
| 150 |
+
" return s2\n",
|
| 151 |
+
"\n",
|
| 152 |
+
"def _limit_emojis(s: str, max_emojis: int, audit: List[str]) -> str:\n",
|
| 153 |
+
" if max_emojis < 0:\n",
|
| 154 |
+
" return s\n",
|
| 155 |
+
" emojis = EMOJI_RX.findall(s)\n",
|
| 156 |
+
" if len(emojis) <= max_emojis:\n",
|
| 157 |
+
" return s\n",
|
| 158 |
+
" kept = 0; out = []\n",
|
| 159 |
+
" for ch in s:\n",
|
| 160 |
+
" if EMOJI_RX.match(ch):\n",
|
| 161 |
+
" if kept < max_emojis:\n",
|
| 162 |
+
" out.append(ch); kept += 1\n",
|
| 163 |
+
" else:\n",
|
| 164 |
+
" out.append(ch)\n",
|
| 165 |
+
" audit.append(f'Trimmed emojis to <= {max_emojis}.')\n",
|
| 166 |
+
" return ''.join(out)\n",
|
| 167 |
+
"\n",
|
| 168 |
+
"def _ensure_keywords(s: str, keywords: List[str], cap: int, audit: List[str]) -> str:\n",
|
| 169 |
+
" text = s\n",
|
| 170 |
+
" for kw in (keywords or []):\n",
|
| 171 |
+
" if kw.strip() and re.search(re.escape(kw), text, flags=re.I) is None:\n",
|
| 172 |
+
" cand = (text + ' ' + kw).strip()\n",
|
| 173 |
+
" if len(cand) <= cap:\n",
|
| 174 |
+
" text = cand\n",
|
| 175 |
+
" else:\n",
|
| 176 |
+
" parts = text.split()\n",
|
| 177 |
+
" if parts:\n",
|
| 178 |
+
" parts[-1] = kw\n",
|
| 179 |
+
" text = ' '.join(parts)\n",
|
| 180 |
+
" audit.append(f'Inserted missing keyword: {kw}')\n",
|
| 181 |
+
" return text\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"def _pick_cta(cta_strength: str) -> str:\n",
|
| 184 |
+
" bank = {\n",
|
| 185 |
+
" 'soft': ['Learn more','See how it works','Try it free'],\n",
|
| 186 |
+
" 'medium': ['Get started today','Start now'],\n",
|
| 187 |
+
" 'hard': ['Start your free trial now','Buy now','Sign up now'],\n",
|
| 188 |
+
" }.get(cta_strength, ['Learn more'])\n",
|
| 189 |
+
" return bank[0]\n",
|
| 190 |
+
"\n",
|
| 191 |
+
"def _ensure_cta(text: str, cta_strength: str, audit: List[str]) -> str:\n",
|
| 192 |
+
" if re.search(r'\\b(learn more|start|try|buy|sign up|get started|discover|explore)\\b', text, flags=re.I):\n",
|
| 193 |
+
" return text\n",
|
| 194 |
+
" cta = _pick_cta(cta_strength)\n",
|
| 195 |
+
" audit.append(f\"Added CTA: '{cta}'.\")\n",
|
| 196 |
+
" return (text + (' ' if not text.endswith(('.', '!', '?')) else ' ') + cta).strip()\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"def _smart_trim(text: str, cap: int, preserve_tail: str = '') -> str:\n",
|
| 199 |
+
" if len(text) <= cap:\n",
|
| 200 |
+
" return text\n",
|
| 201 |
+
" reserve = len(preserve_tail) + (1 if preserve_tail and not text.endswith(' ') else 0)\n",
|
| 202 |
+
" hard = max(0, cap - reserve)\n",
|
| 203 |
+
" trimmed = text[:hard].rstrip()\n",
|
| 204 |
+
" if preserve_tail:\n",
|
| 205 |
+
" if not trimmed.endswith(('.', '!', '?')):\n",
|
| 206 |
+
" trimmed = trimmed.rstrip(',;:-')\n",
|
| 207 |
+
" trimmed = (trimmed + ' ' + preserve_tail).strip()\n",
|
| 208 |
+
" return trimmed[:cap]\n",
|
| 209 |
+
"\n",
|
| 210 |
+
"def apply_validators(text: str, platform: str, cap: int, cta_strength: str, keywords: List[str]):\n",
|
| 211 |
+
" audit: List[str] = []\n",
|
| 212 |
+
" s = text.strip()\n",
|
| 213 |
+
" s = _replace_banned(s, audit)\n",
|
| 214 |
+
" s = _ensure_cta(s, cta_strength, audit)\n",
|
| 215 |
+
" s = _ensure_keywords(s, keywords, 10**9, audit)\n",
|
| 216 |
+
" tail_cta = ''\n",
|
| 217 |
+
" m = re.search(r'(learn more|start your free trial(?: now)?|start now|try it free|get started(?: today)?|buy now|sign up(?: now)?)$', s, flags=re.I)\n",
|
| 218 |
+
" if m:\n",
|
| 219 |
+
" tail_cta = m.group(0)\n",
|
| 220 |
+
" s = _smart_trim(s, cap, preserve_tail=tail_cta)\n",
|
| 221 |
+
" s = _ensure_keywords(s, keywords, cap, audit)\n",
|
| 222 |
+
" rule = PLATFORM_RULES.get(platform, PLATFORM_RULES['Instagram'])\n",
|
| 223 |
+
" s = _limit_hashtags(s, rule['hashtags_max'], audit)\n",
|
| 224 |
+
" s = _limit_emojis(s, rule['emoji_max'], audit)\n",
|
| 225 |
+
" if len(s) > cap:\n",
|
| 226 |
+
" s = s[:cap].rstrip()\n",
|
| 227 |
+
" audit.append(f'Force-clipped to {cap} chars.')\n",
|
| 228 |
+
" return s, audit\n"
|
| 229 |
+
]
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"cell_type": "code",
|
| 233 |
+
"execution_count": null,
|
| 234 |
+
"metadata": {},
|
| 235 |
+
"outputs": [],
|
| 236 |
+
"source": [
|
| 237 |
+
"class ShortWindowMemory:\n",
|
| 238 |
+
" def __init__(self, k: int = 3):\n",
|
| 239 |
+
" self.window = deque(maxlen=k)\n",
|
| 240 |
+
" def add(self, user: str, assistant: str):\n",
|
| 241 |
+
" self.window.append({'user': user, 'assistant': assistant})\n",
|
| 242 |
+
" def text(self) -> str:\n",
|
| 243 |
+
" if not self.window:\n",
|
| 244 |
+
" return ''\n",
|
| 245 |
+
" lines = []\n",
|
| 246 |
+
" for t in self.window:\n",
|
| 247 |
+
" lines.append(f\"Human: {t['user']}\")\n",
|
| 248 |
+
" lines.append(f\"AI: {t['assistant']}\")\n",
|
| 249 |
+
" return '\\n'.join(lines)\n",
|
| 250 |
+
" def reset(self):\n",
|
| 251 |
+
" self.window.clear()\n"
|
| 252 |
+
]
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"cell_type": "code",
|
| 256 |
+
"execution_count": null,
|
| 257 |
+
"metadata": {},
|
| 258 |
+
"outputs": [],
|
| 259 |
+
"source": [
|
| 260 |
+
"SESSION_PROFILE: Dict[str, Any] = {\n",
|
| 261 |
+
" 'brand': '', 'product': '', 'audience': '', 'voice': '', 'facts': {}\n",
|
| 262 |
+
"}\n",
|
| 263 |
+
"def remember(k: str, v: str):\n",
|
| 264 |
+
" if k in ('brand','product','audience','voice'):\n",
|
| 265 |
+
" SESSION_PROFILE[k] = v\n",
|
| 266 |
+
" else:\n",
|
| 267 |
+
" SESSION_PROFILE['facts'][k] = v\n",
|
| 268 |
+
" return SESSION_PROFILE\n",
|
| 269 |
+
"def forget(k: str):\n",
|
| 270 |
+
" if k in ('brand','product','audience','voice'):\n",
|
| 271 |
+
" SESSION_PROFILE[k] = ''\n",
|
| 272 |
+
" else:\n",
|
| 273 |
+
" SESSION_PROFILE['facts'].pop(k, None)\n",
|
| 274 |
+
" return SESSION_PROFILE\n",
|
| 275 |
+
"def facts_dump() -> str:\n",
|
| 276 |
+
" f = [f\"- brand: {SESSION_PROFILE.get('brand','')}\",\n",
|
| 277 |
+
" f\"- product: {SESSION_PROFILE.get('product','')}\",\n",
|
| 278 |
+
" f\"- audience: {SESSION_PROFILE.get('audience','')}\",\n",
|
| 279 |
+
" f\"- voice: {SESSION_PROFILE.get('voice','')}\"]\n",
|
| 280 |
+
" if SESSION_PROFILE['facts']:\n",
|
| 281 |
+
" f.append('- facts:')\n",
|
| 282 |
+
" for k,v in SESSION_PROFILE['facts'].items():\n",
|
| 283 |
+
" f.append(f\" • {k}: {v}\")\n",
|
| 284 |
+
" return '\\n'.join(f)\n",
|
| 285 |
+
"BASE_GUIDANCE = (\n",
|
| 286 |
+
" 'You are Marketeer, a concise, benefit-first marketing copywriter. '\n",
|
| 287 |
+
" 'Respect the platform\\'s character cap, include required keywords, and end with a clear but compliant CTA. '\n",
|
| 288 |
+
" \"Avoid absolute claims like 'guaranteed', '#1', or 'best'. Prefer modest, evidence-backed phrasing.\"\n",
|
| 289 |
+
")\n",
|
| 290 |
+
"def _profile_block() -> str:\n",
|
| 291 |
+
" lines = []\n",
|
| 292 |
+
" if any(SESSION_PROFILE.values()):\n",
|
| 293 |
+
" lines.append('Session profile:')\n",
|
| 294 |
+
" if SESSION_PROFILE.get('brand'): lines.append(f\"- Brand: {SESSION_PROFILE['brand']}\")\n",
|
| 295 |
+
" if SESSION_PROFILE.get('product'): lines.append(f\"- Product: {SESSION_PROFILE['product']}\")\n",
|
| 296 |
+
" if SESSION_PROFILE.get('audience'):lines.append(f\"- Audience: {SESSION_PROFILE['audience']}\")\n",
|
| 297 |
+
" if SESSION_PROFILE.get('voice'): lines.append(f\"- Voice: {SESSION_PROFILE['voice']}\")\n",
|
| 298 |
+
" if SESSION_PROFILE['facts']:\n",
|
| 299 |
+
" lines.append('- Key facts:')\n",
|
| 300 |
+
" for k,v in SESSION_PROFILE['facts'].items():\n",
|
| 301 |
+
" lines.append(f\" • {k}: {v}\")\n",
|
| 302 |
+
" return '\\n'.join(lines) if lines else '[no session profile]'\n",
|
| 303 |
+
"import textwrap\n",
|
| 304 |
+
"def build_prompt(user_input: str, platform: str, tone: str, cta_strength: str, cap: int,\n",
|
| 305 |
+
" keywords: List[str], history_text: str = '') -> str:\n",
|
| 306 |
+
" kw = ', '.join(keywords) if keywords else '(none)'\n",
|
| 307 |
+
" profile = _profile_block()\n",
|
| 308 |
+
" return textwrap.dedent(f\"\"\"\n",
|
| 309 |
+
" {BASE_GUIDANCE}\n",
|
| 310 |
+
"\n",
|
| 311 |
+
" Context (recent conversation, if any):\n",
|
| 312 |
+
" {history_text if history_text else '[no prior turns in memory]'}\n",
|
| 313 |
+
"\n",
|
| 314 |
+
" {profile}\n",
|
| 315 |
+
"\n",
|
| 316 |
+
" Task:\n",
|
| 317 |
+
" - Platform: {platform}\n",
|
| 318 |
+
" - Tone: {tone}\n",
|
| 319 |
+
" - CTA strength: {cta_strength}\n",
|
| 320 |
+
" - Character cap: {cap}\n",
|
| 321 |
+
" - Required keywords: {kw}\n",
|
| 322 |
+
"\n",
|
| 323 |
+
" User request:\n",
|
| 324 |
+
" {user_input}\n",
|
| 325 |
+
"\n",
|
| 326 |
+
" Instructions:\n",
|
| 327 |
+
" - Be benefit-first and platform-appropriate.\n",
|
| 328 |
+
" - Keep within the character cap (hard limit {cap} chars).\n",
|
| 329 |
+
" - Include all required keywords (if any).\n",
|
| 330 |
+
" - Close with a clear CTA matching CTA strength.\n",
|
| 331 |
+
" - Avoid banned claims ('guaranteed', '#1', 'best').\n",
|
| 332 |
+
"\n",
|
| 333 |
+
" Return only the marketing copy (no preamble).\n",
|
| 334 |
+
" \"\"\").strip()\n"
|
| 335 |
+
]
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"cell_type": "code",
|
| 339 |
+
"execution_count": null,
|
| 340 |
+
"metadata": {},
|
| 341 |
+
"outputs": [],
|
| 342 |
+
"source": [
|
| 343 |
+
"import getpass\n",
|
| 344 |
+
"MODEL_ID = 'google/gemma-2-2b-it'\n",
|
| 345 |
+
"DTYPE = 'bfloat16'\n",
|
| 346 |
+
"try:\n",
|
| 347 |
+
" token = getpass.getpass('Enter HF token (press Enter to skip): ')\n",
|
| 348 |
+
" HF_TOKEN = token.strip() or None\n",
|
| 349 |
+
"except Exception:\n",
|
| 350 |
+
" HF_TOKEN = None\n",
|
| 351 |
+
"torch_dtype = {'bfloat16': torch.bfloat16, 'float16': torch.float16}.get(DTYPE, torch.bfloat16)\n",
|
| 352 |
+
"tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)\n",
|
| 353 |
+
"if tokenizer.pad_token is None:\n",
|
| 354 |
+
" tokenizer.pad_token = tokenizer.eos_token\n",
|
| 355 |
+
"model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch_dtype, device_map='auto', token=HF_TOKEN)\n",
|
| 356 |
+
"print({'model': MODEL_ID, 'dtype': str(torch_dtype).replace('torch.', '')})"
|
| 357 |
+
]
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"cell_type": "code",
|
| 361 |
+
"execution_count": null,
|
| 362 |
+
"metadata": {},
|
| 363 |
+
"outputs": [],
|
| 364 |
+
"source": [
|
| 365 |
+
"memory = ShortWindowMemory(k=3)\n",
|
| 366 |
+
"DEFAULTS = {'platform': 'Instagram', 'tone': 'friendly, energetic', 'cta': 'soft'}\n",
|
| 367 |
+
"def _generate(prompt_text: str, max_new_tokens=180, temperature=0.7, top_p=0.9, repetition_penalty=1.1):\n",
|
| 368 |
+
" messages = [{'role': 'user', 'content': prompt_text}]\n",
|
| 369 |
+
" input_ids = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, return_tensors='pt').to(model.device)\n",
|
| 370 |
+
" with torch.no_grad():\n",
|
| 371 |
+
" out = model.generate(\n",
|
| 372 |
+
" input_ids=input_ids,\n",
|
| 373 |
+
" max_new_tokens=max_new_tokens,\n",
|
| 374 |
+
" temperature=temperature,\n",
|
| 375 |
+
" top_p=top_p,\n",
|
| 376 |
+
" repetition_penalty=repetition_penalty,\n",
|
| 377 |
+
" do_sample=True,\n",
|
| 378 |
+
" pad_token_id=tokenizer.pad_token_id,\n",
|
| 379 |
+
" eos_token_id=tokenizer.eos_token_id,\n",
|
| 380 |
+
" )\n",
|
| 381 |
+
" gen_ids = out[0][input_ids.shape[-1]:]\n",
|
| 382 |
+
" return tokenizer.decode(gen_ids, skip_special_tokens=True).strip()\n",
|
| 383 |
+
"def send(user: str, platform: str=None, tone: str=None, cta_strength: str=None, cap: int=None, keywords: List[str]=None,\n",
|
| 384 |
+
" max_new_tokens=180, temperature=0.7, top_p=0.9, repetition_penalty=1.1):\n",
|
| 385 |
+
" platform = platform or DEFAULTS['platform']\n",
|
| 386 |
+
" tone = tone or DEFAULTS['tone']\n",
|
| 387 |
+
" cta_strength = cta_strength or DEFAULTS['cta']\n",
|
| 388 |
+
" cap = cap or PLATFORM_RULES.get(platform, PLATFORM_RULES['Instagram'])['cap']\n",
|
| 389 |
+
" keywords = keywords or []\n",
|
| 390 |
+
" prompt_text = build_prompt(user, platform, tone, cta_strength, cap, keywords, memory.text())\n",
|
| 391 |
+
" raw = _generate(prompt_text, max_new_tokens, temperature, top_p, repetition_penalty)\n",
|
| 392 |
+
" final, audit = apply_validators(raw, platform, cap, cta_strength, keywords)\n",
|
| 393 |
+
" memory.add(user, final)\n",
|
| 394 |
+
" return {'raw': raw, 'final': final, 'audit': audit, 'cap': cap}\n"
|
| 395 |
+
]
|
| 396 |
+
},
|
| 397 |
+
{
|
| 398 |
+
"cell_type": "code",
|
| 399 |
+
"execution_count": null,
|
| 400 |
+
"metadata": {},
|
| 401 |
+
"outputs": [],
|
| 402 |
+
"source": [
|
| 403 |
+
"VIDEO_BLUEPRINTS = {\n",
|
| 404 |
+
" 'short_ad': ['Hook (2-3s)','Problem (3-5s)','Product intro (4-6s)','Key benefit (4-6s)','CTA (2-3s)'],\n",
|
| 405 |
+
" 'ugc_review': ['Relatable hook','Pain point','Discovery','Feature demo','Social proof','CTA'],\n",
|
| 406 |
+
" 'how_to': ['Teaser result','Step 1','Step 2','Step 3','Recap + CTA'],\n",
|
| 407 |
+
"}\n",
|
| 408 |
+
"def plan_video(blueprint: str='short_ad', duration_sec: int=20, product_brief: str='') -> Dict[str, Any]:\n",
|
| 409 |
+
" if blueprint not in VIDEO_BLUEPRINTS:\n",
|
| 410 |
+
" raise ValueError(f\"Unknown blueprint '{blueprint}'. Options: {list(VIDEO_BLUEPRINTS.keys())}\")\n",
|
| 411 |
+
" beats = VIDEO_BLUEPRINTS[blueprint]\n",
|
| 412 |
+
" per_beat = max(2, duration_sec // max(1, len(beats)))\n",
|
| 413 |
+
" plan = []\n",
|
| 414 |
+
" for i, beat in enumerate(beats, 1):\n",
|
| 415 |
+
" plan.append({\n",
|
| 416 |
+
" 'order': i,\n",
|
| 417 |
+
" 'beat': beat,\n",
|
| 418 |
+
" 'time_window': f\"{(i-1)*per_beat:02d}-{min(i*per_beat, duration_sec):02d}s\",\n",
|
| 419 |
+
" })\n",
|
| 420 |
+
" return {\n",
|
| 421 |
+
" 'blueprint': blueprint,\n",
|
| 422 |
+
" 'duration_sec': duration_sec,\n",
|
| 423 |
+
" 'beats': plan,\n",
|
| 424 |
+
" 'product_brief': product_brief or '(use chat context)',\n",
|
| 425 |
+
" }\n"
|
| 426 |
+
]
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"cell_type": "code",
|
| 430 |
+
"execution_count": null,
|
| 431 |
+
"metadata": {},
|
| 432 |
+
"outputs": [],
|
| 433 |
+
"source": [
|
| 434 |
+
"import json as _json, re as _re\n",
|
| 435 |
+
"\n",
|
| 436 |
+
"def _session_context_text():\n",
|
| 437 |
+
" history_text = memory.text()\n",
|
| 438 |
+
" lines = []\n",
|
| 439 |
+
" lines.append('=== SESSION PROFILE ===')\n",
|
| 440 |
+
" if SESSION_PROFILE.get('brand'): lines.append(f\"Brand: {SESSION_PROFILE['brand']}\")\n",
|
| 441 |
+
" if SESSION_PROFILE.get('product'): lines.append(f\"Product: {SESSION_PROFILE['product']}\")\n",
|
| 442 |
+
" if SESSION_PROFILE.get('audience'):lines.append(f\"Audience: {SESSION_PROFILE['audience']}\")\n",
|
| 443 |
+
" if SESSION_PROFILE.get('voice'): lines.append(f\"Preferred Voice: {SESSION_PROFILE['voice']}\")\n",
|
| 444 |
+
" if SESSION_PROFILE.get('facts'):\n",
|
| 445 |
+
" lines.append('Facts:')\n",
|
| 446 |
+
" for k,v in SESSION_PROFILE['facts'].items():\n",
|
| 447 |
+
" lines.append(f\"- {k}: {v}\")\n",
|
| 448 |
+
" lines.append('\\n=== RECENT CONVERSATION ===')\n",
|
| 449 |
+
" lines.append(history_text if history_text else '[none]')\n",
|
| 450 |
+
" return '\\n'.join(lines)\n",
|
| 451 |
+
"\n",
|
| 452 |
+
"def _decode_tail(out_ids, start_idx):\n",
|
| 453 |
+
" return tokenizer.decode(out_ids[0][start_idx:], skip_special_tokens=True).strip()\n",
|
| 454 |
+
"\n",
|
| 455 |
+
"def _just_user_prompt(prompt_text: str, max_new_tokens=320):\n",
|
| 456 |
+
" messages = [{'role':'user','content': prompt_text}]\n",
|
| 457 |
+
" input_ids = tokenizer.apply_chat_template(\n",
|
| 458 |
+
" messages, tokenize=True, add_generation_prompt=True, return_tensors='pt'\n",
|
| 459 |
+
" ).to(model.device)\n",
|
| 460 |
+
" with torch.no_grad():\n",
|
| 461 |
+
" out = model.generate(\n",
|
| 462 |
+
" input_ids=input_ids,\n",
|
| 463 |
+
" max_new_tokens=max_new_tokens,\n",
|
| 464 |
+
" temperature=0.6,\n",
|
| 465 |
+
" top_p=0.9,\n",
|
| 466 |
+
" repetition_penalty=1.1,\n",
|
| 467 |
+
" do_sample=True,\n",
|
| 468 |
+
" pad_token_id=tokenizer.pad_token_id,\n",
|
| 469 |
+
" eos_token_id=tokenizer.eos_token_id,\n",
|
| 470 |
+
" )\n",
|
| 471 |
+
" return _decode_tail(out, input_ids.shape[-1])\n",
|
| 472 |
+
"\n",
|
| 473 |
+
"def _extract_json(gen_text: str):\n",
|
| 474 |
+
" try:\n",
|
| 475 |
+
" return _json.loads(gen_text)\n",
|
| 476 |
+
" except Exception:\n",
|
| 477 |
+
" pass\n",
|
| 478 |
+
" m = _re.search(r'\\{.*\\}', gen_text, flags=_re.S)\n",
|
| 479 |
+
" if m:\n",
|
| 480 |
+
" try:\n",
|
| 481 |
+
" return _json.loads(m.group(0))\n",
|
| 482 |
+
" except Exception:\n",
|
| 483 |
+
" pass\n",
|
| 484 |
+
" return None\n",
|
| 485 |
+
"\n",
|
| 486 |
+
"def _fallback_block(beat_title: str):\n",
|
| 487 |
+
" short = beat_title.split('(')[0].strip()\n",
|
| 488 |
+
" return {\n",
|
| 489 |
+
" 'voiceover': f\"{short}: naturally delicious, try it today.\",\n",
|
| 490 |
+
" 'on_screen': (short[:32] or 'Fresh & Natural'),\n",
|
| 491 |
+
" 'shots': ['Close-up product', 'Serving scoop', 'Happy bite'],\n",
|
| 492 |
+
" 'broll': ['Farm/ingredient cutaways', 'Pouring/serving'],\n",
|
| 493 |
+
" 'captions': ['Naturally made ice cream', 'From local farms'],\n",
|
| 494 |
+
" }\n",
|
| 495 |
+
"\n",
|
| 496 |
+
"def script_video_from_plan(plan: dict, style: str = 'friendly, energetic', platform: str = 'Instagram', debug_first=False):\n",
|
| 497 |
+
" context = _session_context_text()\n",
|
| 498 |
+
" blueprint = plan.get('blueprint', '?')\n",
|
| 499 |
+
" duration = plan.get('duration_sec', 20)\n",
|
| 500 |
+
" beats = plan.get('beats', [])\n",
|
| 501 |
+
" scripted_beats = []\n",
|
| 502 |
+
"\n",
|
| 503 |
+
" for idx, b in enumerate(beats):\n",
|
| 504 |
+
" brief = (\n",
|
| 505 |
+
" context + '\\n\\n'\n",
|
| 506 |
+
" '=== VIDEO BLUEPRINT ===\\n'\n",
|
| 507 |
+
" f'Type: {blueprint} | Duration: {duration}s\\n'\n",
|
| 508 |
+
" f\"Current beat: {b['order']} — {b['beat']} ({b['time_window']})\\n\\n\"\n",
|
| 509 |
+
" 'Write concise items with the following constraints:\\n'\n",
|
| 510 |
+
" '- voiceover: <= 18 words, benefits-first, natural.\\n'\n",
|
| 511 |
+
" '- on_screen: <= 36 characters, punchy overlay text.\\n'\n",
|
| 512 |
+
" '- shots: 3 ideas, short imperatives (e.g., \"Close-up pour\").\\n'\n",
|
| 513 |
+
" '- broll: 2 ideas, short.\\n'\n",
|
| 514 |
+
" '- captions: 1–2 lines, each <= 40 characters.\\n\\n'\n",
|
| 515 |
+
" f'Platform: {platform}\\n'\n",
|
| 516 |
+
" f'Style/Tone: {style}\\n\\n'\n",
|
| 517 |
+
" 'Return ONLY valid JSON with keys:\\n'\n",
|
| 518 |
+
" '{\\n \"voiceover\": \"string\",\\n \"on_screen\": \"string\",\\n \"shots\": [\"...\", \"...\", \"...\"],\\n \"broll\": [\"...\", \"...\"],\\n \"captions\": [\"...\", \"...\"]\\n}'\n",
|
| 519 |
+
" )\n",
|
| 520 |
+
" gen_text = _just_user_prompt(brief, max_new_tokens=320)\n",
|
| 521 |
+
" if debug_first and idx == 0:\n",
|
| 522 |
+
" print('RAW (beat 1):\\n', gen_text)\n",
|
| 523 |
+
" data = _extract_json(gen_text)\n",
|
| 524 |
+
" if not data:\n",
|
| 525 |
+
" data = _fallback_block(b.get('beat','Beat'))\n",
|
| 526 |
+
"\n",
|
| 527 |
+
" scripted_beats.append({\n",
|
| 528 |
+
" 'order': b.get('order'),\n",
|
| 529 |
+
" 'time_window': b.get('time_window'),\n",
|
| 530 |
+
" 'beat': b.get('beat'),\n",
|
| 531 |
+
" 'voiceover': data.get('voiceover','') or _fallback_block(b.get('beat',''))['voiceover'],\n",
|
| 532 |
+
" 'on_screen': data.get('on_screen','') or _fallback_block(b.get('beat',''))['on_screen'],\n",
|
| 533 |
+
" 'shots': (data.get('shots') or _fallback_block(b.get('beat',''))['shots'])[:3],\n",
|
| 534 |
+
" 'broll': (data.get('broll') or _fallback_block(b.get('beat',''))['broll'])[:2],\n",
|
| 535 |
+
" 'captions': (data.get('captions') or _fallback_block(b.get('beat',''))['captions'])[:2],\n",
|
| 536 |
+
" })\n",
|
| 537 |
+
"\n",
|
| 538 |
+
" return {\n",
|
| 539 |
+
" 'blueprint': blueprint,\n",
|
| 540 |
+
" 'duration_sec': duration,\n",
|
| 541 |
+
" 'style': style,\n",
|
| 542 |
+
" 'platform': platform,\n",
|
| 543 |
+
" 'product_brief': plan.get('product_brief',''),\n",
|
| 544 |
+
" 'script': scripted_beats\n",
|
| 545 |
+
" }\n",
|
| 546 |
+
"\n",
|
| 547 |
+
"def make_video(plan_or_blueprint='short_ad', duration=20, product_brief='', style='friendly, energetic', platform='Instagram', debug_first=False):\n",
|
| 548 |
+
" if isinstance(plan_or_blueprint, dict):\n",
|
| 549 |
+
" plan = plan_or_blueprint\n",
|
| 550 |
+
" else:\n",
|
| 551 |
+
" plan = plan_video(plan_or_blueprint, duration, product_brief)\n",
|
| 552 |
+
" return script_video_from_plan(plan, style=style, platform=platform, debug_first=debug_first)\n"
|
| 553 |
+
]
|
| 554 |
+
},
|
| 555 |
+
{
|
| 556 |
+
"cell_type": "code",
|
| 557 |
+
"execution_count": null,
|
| 558 |
+
"metadata": {},
|
| 559 |
+
"outputs": [],
|
| 560 |
+
"source": [
|
| 561 |
+
"from rich.console import Console\n",
|
| 562 |
+
"from rich.table import Table\n",
|
| 563 |
+
"from rich.panel import Panel\n",
|
| 564 |
+
"from rich.markdown import Markdown\n",
|
| 565 |
+
"import re, json as _j\n",
|
| 566 |
+
"\n",
|
| 567 |
+
"console = Console()\n",
|
| 568 |
+
"def _print_header():\n",
|
| 569 |
+
" t = Table(title='Marketeer — REPL (Patched)', show_lines=False)\n",
|
| 570 |
+
" t.add_column('Setting', style='cyan', no_wrap=True)\n",
|
| 571 |
+
" t.add_column('Value', style='white')\n",
|
| 572 |
+
" t.add_row('Model', MODEL_ID)\n",
|
| 573 |
+
" t.add_row('Platform', 'Instagram')\n",
|
| 574 |
+
" t.add_row('Tone', 'friendly, energetic')\n",
|
| 575 |
+
" t.add_row('CTA', 'soft')\n",
|
| 576 |
+
" console.print(t)\n",
|
| 577 |
+
" console.print(Markdown(\n",
|
| 578 |
+
" '**Commands**\\n'\n",
|
| 579 |
+
" '- `/remember key=value`, `/forget key`, `/facts`\\n'\n",
|
| 580 |
+
" '- `/video blueprint=short_ad duration=20 style=warm platform=Instagram`\\n'\n",
|
| 581 |
+
" '- Type any prompt to generate copy.'\n",
|
| 582 |
+
" ))\n",
|
| 583 |
+
"\n",
|
| 584 |
+
"def _parse_kv(line: str):\n",
|
| 585 |
+
" parts = re.findall(r'(\\w+)=(\".*?\"|\\'.*?\\'|\\S+)', line)\n",
|
| 586 |
+
" out = {}\n",
|
| 587 |
+
" for k, v in parts:\n",
|
| 588 |
+
" v = v.strip().strip('\"').strip(\"'\")\n",
|
| 589 |
+
" out[k] = v\n",
|
| 590 |
+
" return out\n",
|
| 591 |
+
"\n",
|
| 592 |
+
"def repl():\n",
|
| 593 |
+
" _print_header()\n",
|
| 594 |
+
" while True:\n",
|
| 595 |
+
" try:\n",
|
| 596 |
+
" line = console.input('[bold magenta]You[/bold magenta]: ').strip()\n",
|
| 597 |
+
" except (KeyboardInterrupt, EOFError):\n",
|
| 598 |
+
" console.print('\\n[yellow]Bye.[/yellow]'); break\n",
|
| 599 |
+
" if not line:\n",
|
| 600 |
+
" continue\n",
|
| 601 |
+
" low = line.lower()\n",
|
| 602 |
+
" if low in ('/exit','exit','quit','/quit'):\n",
|
| 603 |
+
" console.print('[yellow]Bye.[/yellow]'); break\n",
|
| 604 |
+
" if low.startswith('/facts'):\n",
|
| 605 |
+
" console.print(Panel(facts_dump() or '(no facts)', title='Session Facts')); continue\n",
|
| 606 |
+
" if low.startswith('/forget'):\n",
|
| 607 |
+
" kv = _parse_kv(line)\n",
|
| 608 |
+
" for k in kv.keys(): forget(k)\n",
|
| 609 |
+
" console.print(Panel('Updated facts.', title='OK')); continue\n",
|
| 610 |
+
" if low.startswith('/remember'):\n",
|
| 611 |
+
" kv = _parse_kv(line)\n",
|
| 612 |
+
" if not kv and '=' in line:\n",
|
| 613 |
+
" raw = line.split(None, 1)[1]\n",
|
| 614 |
+
" k,v = raw.split('=',1)\n",
|
| 615 |
+
" remember(k.strip(), v.strip())\n",
|
| 616 |
+
" else:\n",
|
| 617 |
+
" for k,v in kv.items(): remember(k, v)\n",
|
| 618 |
+
" console.print(Panel('Saved.', title='OK')); continue\n",
|
| 619 |
+
" if low.startswith('/video'):\n",
|
| 620 |
+
" kv = _parse_kv(line)\n",
|
| 621 |
+
" blueprint = kv.get('blueprint','short_ad')\n",
|
| 622 |
+
" duration = int(kv.get('duration','20'))\n",
|
| 623 |
+
" style = kv.get('style','friendly, energetic')\n",
|
| 624 |
+
" platformV = kv.get('platform','Instagram')\n",
|
| 625 |
+
" brief = SESSION_PROFILE.get('product','') or SESSION_PROFILE.get('brand','') or 'marketing video'\n",
|
| 626 |
+
" plan = plan_video(blueprint, duration, brief)\n",
|
| 627 |
+
" script = make_video(plan, style=style, platform=platformV, debug_first=True)\n",
|
| 628 |
+
" console.print(Panel(_j.dumps(script, indent=2), title='Video Script'))\n",
|
| 629 |
+
" continue\n",
|
| 630 |
+
" # normal prompt\n",
|
| 631 |
+
" result = send(line)\n",
|
| 632 |
+
" console.print(Panel(result['final'], title='Response', subtitle=f\"len={len(result['final'])}/{result['cap']}\"))\n",
|
| 633 |
+
" if result['audit']:\n",
|
| 634 |
+
" md = '\\n'.join([f\"- {a}\" for a in result['audit']])\n",
|
| 635 |
+
" console.print(Panel(md, title='Audit trail'))\n",
|
| 636 |
+
" else:\n",
|
| 637 |
+
" console.print('[dim]No edits needed.[/dim]')\n",
|
| 638 |
+
"\n",
|
| 639 |
+
"repl()\n"
|
| 640 |
+
]
|
| 641 |
+
},
|
| 642 |
+
{
|
| 643 |
+
"cell_type": "markdown",
|
| 644 |
+
"metadata": {},
|
| 645 |
+
"source": [
|
| 646 |
+
"## Quick self‑test (optional)"
|
| 647 |
+
]
|
| 648 |
+
},
|
| 649 |
+
{
|
| 650 |
+
"cell_type": "code",
|
| 651 |
+
"execution_count": null,
|
| 652 |
+
"metadata": {},
|
| 653 |
+
"outputs": [],
|
| 654 |
+
"source": [
|
| 655 |
+
"# Uncomment to smoke test video scripting\n",
|
| 656 |
+
"# remember('brand','FrostFields')\n",
|
| 657 |
+
"# remember('product','Natural fruit ice cream')\n",
|
| 658 |
+
"# remember('origin','Local farms')\n",
|
| 659 |
+
"# plan = plan_video('short_ad', 20, 'Natural ice cream with coconut & apple')\n",
|
| 660 |
+
"# vid = make_video(plan, style='warm, wholesome', platform='Instagram', debug_first=True)\n",
|
| 661 |
+
"# import json as j; print(j.dumps(vid, indent=2))\n"
|
| 662 |
+
]
|
| 663 |
+
}
|
| 664 |
+
],
|
| 665 |
+
"metadata": {
|
| 666 |
+
"colab": {
|
| 667 |
+
"name": "Marketeer_Patched_Video.ipynb"
|
| 668 |
+
},
|
| 669 |
+
"kernelspec": {
|
| 670 |
+
"display_name": "ai",
|
| 671 |
+
"language": "python",
|
| 672 |
+
"name": "python3"
|
| 673 |
+
},
|
| 674 |
+
"language_info": {
|
| 675 |
+
"codemirror_mode": {
|
| 676 |
+
"name": "ipython",
|
| 677 |
+
"version": 3
|
| 678 |
+
},
|
| 679 |
+
"file_extension": ".py",
|
| 680 |
+
"mimetype": "text/x-python",
|
| 681 |
+
"name": "python",
|
| 682 |
+
"nbconvert_exporter": "python",
|
| 683 |
+
"pygments_lexer": "ipython3",
|
| 684 |
+
"version": "3.10.11"
|
| 685 |
+
}
|
| 686 |
+
},
|
| 687 |
+
"nbformat": 4,
|
| 688 |
+
"nbformat_minor": 5
|
| 689 |
+
}
|
README.md
CHANGED
|
@@ -1,12 +1 @@
|
|
| 1 |
-
|
| 2 |
-
title: Marketeer
|
| 3 |
-
emoji: 🌖
|
| 4 |
-
colorFrom: purple
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.0.2
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
---
|
| 11 |
-
|
| 12 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
+
# Marketing_chat_bot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
App entry point for Marketeer on Hugging Face Spaces (and local use).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from ui.gradio_ui import create_interface
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Hugging Face Spaces will run this file.
|
| 9 |
+
# Locally, you can run: python app.py
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
demo = create_interface()
|
| 12 |
+
# On Spaces, you typically don't need any special args.
|
| 13 |
+
demo.launch()
|
blueprint.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Nice, let’s turn all that into a **clean roadmap** you can actually work through, phase by phase. Think of this as the “Marketeer x LangChain x HF” blueprint you can keep coming back to.
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
# 🧭 Marketeer Roadmap (LangChain + Hugging Face)
|
| 6 |
+
|
| 7 |
+
## Phase 0 – Current Baseline (where you are now)
|
| 8 |
+
|
| 9 |
+
**You already have:**
|
| 10 |
+
|
| 11 |
+
* Local HF model (Gemma) running on RTX 3060.
|
| 12 |
+
* Core modules:
|
| 13 |
+
|
| 14 |
+
* `core_logic/llm_client.py` (manual `generate_text`)
|
| 15 |
+
* `core_logic/copy_pipeline.py` (template-based copy + validators)
|
| 16 |
+
* `core_logic/video_pipeline.py` (video beats + parsing + validators)
|
| 17 |
+
* `ui/gradio_ui.py` (Gradio UI: Copy tab + Video tab)
|
| 18 |
+
* Simple chat mode using:
|
| 19 |
+
|
| 20 |
+
* `core_logic/chat_chain.py` (PromptTemplate + our custom MarketeerLLM / now simplified)
|
| 21 |
+
* Platform rules + validators from the notebook.
|
| 22 |
+
|
| 23 |
+
We’ll **evolve this** instead of throwing it away.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## Phase 1 – Switch to ChatHuggingFace Backend
|
| 28 |
+
|
| 29 |
+
**Goal:** Use **official LangChain-HuggingFace** integration instead of a custom wrapper, so future features (tools, structured outputs, etc.) are easier.
|
| 30 |
+
|
| 31 |
+
### 1.1. Dependencies
|
| 32 |
+
|
| 33 |
+
* Add to `requirements.txt`:
|
| 34 |
+
|
| 35 |
+
```txt
|
| 36 |
+
langchain-huggingface
|
| 37 |
+
langchain-core
|
| 38 |
+
transformers
|
| 39 |
+
accelerate
|
| 40 |
+
bitsandbytes
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 1.2. LLM config module
|
| 44 |
+
|
| 45 |
+
Create something like `core_logic/llm_config.py`:
|
| 46 |
+
|
| 47 |
+
* **Local dev (pipeline-based):**
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
from langchain_huggingface import ChatHuggingFace, HuggingFacePipeline
|
| 51 |
+
from transformers import BitsAndBytesConfig
|
| 52 |
+
|
| 53 |
+
def get_local_chat_model():
|
| 54 |
+
quant_config = BitsAndBytesConfig(
|
| 55 |
+
load_in_4bit=True,
|
| 56 |
+
bnb_4bit_quant_type="nf4",
|
| 57 |
+
bnb_4bit_compute_dtype="float16",
|
| 58 |
+
bnb_4bit_use_double_quant=True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
base_llm = HuggingFacePipeline.from_model_id(
|
| 62 |
+
model_id="google/gemma-2-2b-it",
|
| 63 |
+
task="text-generation",
|
| 64 |
+
pipeline_kwargs=dict(
|
| 65 |
+
max_new_tokens=256,
|
| 66 |
+
do_sample=True,
|
| 67 |
+
temperature=0.8,
|
| 68 |
+
top_p=0.9,
|
| 69 |
+
return_full_text=False,
|
| 70 |
+
),
|
| 71 |
+
model_kwargs={"quantization_config": quant_config},
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return ChatHuggingFace(llm=base_llm)
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
* Later we’ll add a `get_endpoint_chat_model()` for Spaces.
|
| 78 |
+
|
| 79 |
+
### 1.3. Deprecate `MarketeerLLM`
|
| 80 |
+
|
| 81 |
+
* Keep `core_logic/llm_client.py` around if other code still uses it.
|
| 82 |
+
* For chat + new logic, use `ChatHuggingFace` from `llm_config.get_local_chat_model()`.
|
| 83 |
+
|
| 84 |
+
**Deliverable:**
|
| 85 |
+
A single `ChatHuggingFace` object you can import anywhere as your main chat LLM.
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Phase 2 – Proper Chat Chain with System + History Messages
|
| 90 |
+
|
| 91 |
+
**Goal:** Make the copy chat flow use **LangChain-style messages** instead of raw strings, so context handling is cleaner.
|
| 92 |
+
|
| 93 |
+
### 2.1. New chat chain file
|
| 94 |
+
|
| 95 |
+
Refactor `core_logic/chat_chain.py`:
|
| 96 |
+
|
| 97 |
+
* Import:
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
| 101 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 102 |
+
from core_logic.llm_config import get_local_chat_model
|
| 103 |
+
from helpers.platform_rules import ...
|
| 104 |
+
from helpers.validators import validate_and_edit
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
* Build a **System prompt** from campaign context + platform rules:
|
| 108 |
+
|
| 109 |
+
```python
|
| 110 |
+
def build_system_message(req, platform_cfg):
|
| 111 |
+
content = f"""
|
| 112 |
+
You are an expert social media marketer.
|
| 113 |
+
|
| 114 |
+
Brand: {req.brand}
|
| 115 |
+
Product/Offer: {req.product}
|
| 116 |
+
Target audience: {req.audience}
|
| 117 |
+
Campaign goal: {req.goal}
|
| 118 |
+
Platform: {platform_cfg.name}
|
| 119 |
+
Tone: {req.tone}
|
| 120 |
+
CTA style: {req.cta_style}
|
| 121 |
+
Extra context: {req.extra_context}
|
| 122 |
+
|
| 123 |
+
Follow platform rules and keep the final post within ~{platform_cfg.char_cap} characters.
|
| 124 |
+
Respond ONLY with the post text (no explanations).
|
| 125 |
+
"""
|
| 126 |
+
return SystemMessage(content=content.strip())
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
* Convert `chat_history` (list `[user, assistant]`) to messages:
|
| 130 |
+
|
| 131 |
+
```python
|
| 132 |
+
def history_to_messages(history_pairs):
|
| 133 |
+
msgs = []
|
| 134 |
+
for u, a in history_pairs:
|
| 135 |
+
if u:
|
| 136 |
+
msgs.append(HumanMessage(content=u))
|
| 137 |
+
if a:
|
| 138 |
+
msgs.append(AIMessage(content=a))
|
| 139 |
+
return msgs
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
* `chat_turn(...)`:
|
| 143 |
+
|
| 144 |
+
```python
|
| 145 |
+
def chat_turn(req, user_message, history_pairs):
|
| 146 |
+
platform_cfg = _get_platform_config(req.platform_name)
|
| 147 |
+
chat_model = get_local_chat_model()
|
| 148 |
+
|
| 149 |
+
system_msg = build_system_message(req, platform_cfg)
|
| 150 |
+
history_msgs = history_to_messages(history_pairs)
|
| 151 |
+
user_msg = HumanMessage(content=user_message)
|
| 152 |
+
|
| 153 |
+
messages = [system_msg] + history_msgs + [user_msg]
|
| 154 |
+
|
| 155 |
+
ai_msg = chat_model.invoke(messages)
|
| 156 |
+
raw_text = ai_msg.content
|
| 157 |
+
final_text, audit = validate_and_edit(raw_text, platform_cfg)
|
| 158 |
+
return final_text, raw_text, audit
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### 2.2. UI stays mostly same
|
| 162 |
+
|
| 163 |
+
* `ui/gradio_ui.py` continues passing `chat_history` and `user_message`.
|
| 164 |
+
* Internally, `chat_turn` now uses **true chat messages** and a **SystemMessage**.
|
| 165 |
+
|
| 166 |
+
**Deliverable:**
|
| 167 |
+
Your chat bot respects system instructions + history in a robust, LangChain-native way.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## Phase 3 – Structured Output for Video Script Generator
|
| 172 |
+
|
| 173 |
+
**Goal:** Instead of fragile JSON/beat parsing, use LangChain’s structured output to get reliable beat objects.
|
| 174 |
+
|
| 175 |
+
### 3.1. Define a Pydantic model
|
| 176 |
+
|
| 177 |
+
In `core_logic/video_schema.py`:
|
| 178 |
+
|
| 179 |
+
```python
|
| 180 |
+
from typing import List
|
| 181 |
+
from pydantic import BaseModel
|
| 182 |
+
|
| 183 |
+
class Beat(BaseModel):
|
| 184 |
+
title: str
|
| 185 |
+
voiceover: str
|
| 186 |
+
on_screen: str
|
| 187 |
+
shots: List[str]
|
| 188 |
+
broll: List[str]
|
| 189 |
+
captions: List[str]
|
| 190 |
+
t_start: float
|
| 191 |
+
t_end: float
|
| 192 |
+
|
| 193 |
+
class VideoPlan(BaseModel):
|
| 194 |
+
blueprint_name: str
|
| 195 |
+
duration_sec: int
|
| 196 |
+
platform_name: str
|
| 197 |
+
style: str
|
| 198 |
+
beats: List[Beat]
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
### 3.2. Use structured output in `video_pipeline`
|
| 202 |
+
|
| 203 |
+
* Build a prompt that instructs the model to output that schema.
|
| 204 |
+
* Use `StructuredTool` or LangChain’s `with_structured_output(VideoPlan)` (depending on version).
|
| 205 |
+
* Replace manual JSON parsing with a direct Pydantic model.
|
| 206 |
+
|
| 207 |
+
**Deliverable:**
|
| 208 |
+
`generate_video_script()` returns a `VideoPlan` object directly, with clean per-beat data and fewer parsing errors.
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Phase 4 – LangChain Memory (Optional but Powerful)
|
| 213 |
+
|
| 214 |
+
**Goal:** Advanced memory if you want more than simple chat history.
|
| 215 |
+
|
| 216 |
+
### 4.1. Token-based memory
|
| 217 |
+
|
| 218 |
+
Use `ConversationTokenBufferMemory` instead of raw `chat_history`:
|
| 219 |
+
|
| 220 |
+
* Wrap it around your `ChatHuggingFace` model.
|
| 221 |
+
* Limit memory by tokens (e.g., last 1024 tokens).
|
| 222 |
+
* Still easy to connect to Gradio by syncing the memory with `chat_history`.
|
| 223 |
+
|
| 224 |
+
### 4.2. Knowledge-style memory (later)
|
| 225 |
+
|
| 226 |
+
If you want persistence per brand/campaign:
|
| 227 |
+
|
| 228 |
+
* Store brand facts in a DB and summarize them per session.
|
| 229 |
+
* Memory retrieval each time a session starts.
|
| 230 |
+
|
| 231 |
+
**Deliverable:**
|
| 232 |
+
Chat sessions that scale better (longer conversations) without bloating context.
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Phase 5 – Tools & Agents for “Smart Marketing Assistant”
|
| 237 |
+
|
| 238 |
+
**Goal:** Turn Marketeer from a “single LLM” into a **tool-using assistant**.
|
| 239 |
+
|
| 240 |
+
### 5.1. Tools
|
| 241 |
+
|
| 242 |
+
Some concrete tools:
|
| 243 |
+
|
| 244 |
+
* `generate_hashtags(copy, platform)`
|
| 245 |
+
* `rewrite_tone(copy, tone)`
|
| 246 |
+
* `check_length(copy, platform)` (wraps your validators)
|
| 247 |
+
* `summarize_campaign(history)`
|
| 248 |
+
|
| 249 |
+
Use LangChain’s tool abstraction (`@tool` or `Tool` class) to define them.
|
| 250 |
+
|
| 251 |
+
### 5.2. Router / Agent
|
| 252 |
+
|
| 253 |
+
Use an agent that decides:
|
| 254 |
+
|
| 255 |
+
* When user asks “shorten this” → use `rewrite_tone`.
|
| 256 |
+
* When user says “give options” → generate variants.
|
| 257 |
+
* When user says “turn this into a video script” → call video generator.
|
| 258 |
+
|
| 259 |
+
**Deliverable:**
|
| 260 |
+
Single chat entry point that can:
|
| 261 |
+
|
| 262 |
+
* Write copy
|
| 263 |
+
* Edit copy
|
| 264 |
+
* Generate video storyboards
|
| 265 |
+
* Explain why choices were made (if you want)
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## Phase 6 – Hugging Face Spaces Deployment Plan
|
| 270 |
+
|
| 271 |
+
**Goal:** Clean, reproducible deployment.
|
| 272 |
+
|
| 273 |
+
### 6.1. Repo structure (already close, just formalize)
|
| 274 |
+
|
| 275 |
+
* `app.py` – Gradio entry
|
| 276 |
+
* `ui/` – UI code
|
| 277 |
+
* `core_logic/` – pipelines, chain logic
|
| 278 |
+
* `helpers/` – platform rules, validators
|
| 279 |
+
* `requirements.txt`
|
| 280 |
+
* `README.md`
|
| 281 |
+
|
| 282 |
+
### 6.2. Backend choice for Spaces
|
| 283 |
+
|
| 284 |
+
* **Dev / personal Space:**
|
| 285 |
+
Use local model (pipeline) in `app.py` (like now).
|
| 286 |
+
* **Production / shared Space:**
|
| 287 |
+
Switch `llm_config.get_local_chat_model()` to `get_endpoint_chat_model()` that uses `HuggingFaceEndpoint` and reads `HUGGINGFACEHUB_API_TOKEN` from secrets.
|
| 288 |
+
|
| 289 |
+
**Deliverable:**
|
| 290 |
+
A Space that others can open and:
|
| 291 |
+
|
| 292 |
+
* Fill brand/product/audience/goal
|
| 293 |
+
* Chat with the Marketeer bot
|
| 294 |
+
* Generate copy + video scripts
|
| 295 |
+
* With stable HF-hosted backend
|
| 296 |
+
|
| 297 |
+
---
|
| 298 |
+
|
| 299 |
+
## Phase 7 – “Strategist Mode” (Bonus)
|
| 300 |
+
|
| 301 |
+
**Goal:** Move beyond just *copy* to *campaign thinking*.
|
| 302 |
+
|
| 303 |
+
Ideas:
|
| 304 |
+
|
| 305 |
+
* A mode that, given brand + budget + timeframe, outputs:
|
| 306 |
+
|
| 307 |
+
* channel mix (IG / LinkedIn / YouTube Shorts)
|
| 308 |
+
* example posts per channel
|
| 309 |
+
* rough posting cadence
|
| 310 |
+
* Use LangChain prompts with a “marketing strategist” SystemMessage.
|
| 311 |
+
* Let user toggle between “Copywriter Mode” and “Strategist Mode” in the UI.
|
| 312 |
+
|
| 313 |
+
**Deliverable:**
|
| 314 |
+
Marketeer feels like a mini marketing partner, not just a caption generator.
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## How to Use This Roadmap
|
| 319 |
+
|
| 320 |
+
You can literally go phase by phase:
|
| 321 |
+
|
| 322 |
+
1. **Phase 1** – Swap to `ChatHuggingFace` backend.
|
| 323 |
+
2. **Phase 2** – Refactor `chat_chain` to use System + messages.
|
| 324 |
+
3. **Phase 3** – Structured outputs for the video planner.
|
| 325 |
+
4. **Phase 4+** – Optional memory / tools / agents / deployment polish.
|
| 326 |
+
|
| 327 |
+
Whenever you’re ready, tell me:
|
| 328 |
+
|
| 329 |
+
> “Let’s start Phase 1 step-by-step”
|
| 330 |
+
|
| 331 |
+
and I’ll write the exact code changes (file-by-file, minimal diff style) to get that phase done.
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
|
check.ipynb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 1,
|
| 6 |
+
"id": "49c5075b",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"name": "stdout",
|
| 11 |
+
"output_type": "stream",
|
| 12 |
+
"text": [
|
| 13 |
+
"Tue Nov 25 03:08:08 2025 \n",
|
| 14 |
+
"+-----------------------------------------------------------------------------------------+\n",
|
| 15 |
+
"| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.4 |\n",
|
| 16 |
+
"|-----------------------------------------+------------------------+----------------------+\n",
|
| 17 |
+
"| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |\n",
|
| 18 |
+
"| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |\n",
|
| 19 |
+
"| | | MIG M. |\n",
|
| 20 |
+
"|=========================================+========================+======================|\n",
|
| 21 |
+
"| 0 Tesla T4 Off | 00000000:00:04.0 Off | 0 |\n",
|
| 22 |
+
"| N/A 45C P8 9W / 70W | 0MiB / 15360MiB | 0% Default |\n",
|
| 23 |
+
"| | | N/A |\n",
|
| 24 |
+
"+-----------------------------------------+------------------------+----------------------+\n",
|
| 25 |
+
" \n",
|
| 26 |
+
"+-----------------------------------------------------------------------------------------+\n",
|
| 27 |
+
"| Processes: |\n",
|
| 28 |
+
"| GPU GI CI PID Type Process name GPU Memory |\n",
|
| 29 |
+
"| ID ID Usage |\n",
|
| 30 |
+
"|=========================================================================================|\n",
|
| 31 |
+
"| No running processes found |\n",
|
| 32 |
+
"+-----------------------------------------------------------------------------------------+\n"
|
| 33 |
+
]
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"source": [
|
| 37 |
+
"!nvidia-smi"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"cell_type": "code",
|
| 42 |
+
"execution_count": null,
|
| 43 |
+
"id": "a227f2ae",
|
| 44 |
+
"metadata": {},
|
| 45 |
+
"outputs": [],
|
| 46 |
+
"source": []
|
| 47 |
+
}
|
| 48 |
+
],
|
| 49 |
+
"metadata": {
|
| 50 |
+
"kernelspec": {
|
| 51 |
+
"display_name": "Python 3 (ipykernel)",
|
| 52 |
+
"language": "python",
|
| 53 |
+
"name": "python3"
|
| 54 |
+
},
|
| 55 |
+
"language_info": {
|
| 56 |
+
"codemirror_mode": {
|
| 57 |
+
"name": "ipython",
|
| 58 |
+
"version": 3
|
| 59 |
+
},
|
| 60 |
+
"file_extension": ".py",
|
| 61 |
+
"mimetype": "text/x-python",
|
| 62 |
+
"name": "python",
|
| 63 |
+
"nbconvert_exporter": "python",
|
| 64 |
+
"pygments_lexer": "ipython3",
|
| 65 |
+
"version": "3.12.12"
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
"nbformat": 4,
|
| 69 |
+
"nbformat_minor": 5
|
| 70 |
+
}
|
core_logic/__init__.py
ADDED
|
File without changes
|
core_logic/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (154 Bytes). View file
|
|
|
core_logic/__pycache__/chat_agent.cpython-310.pyc
ADDED
|
Binary file (5.03 kB). View file
|
|
|
core_logic/__pycache__/chat_chain.cpython-310.pyc
ADDED
|
Binary file (4.65 kB). View file
|
|
|
core_logic/__pycache__/copy_pipeline.cpython-310.pyc
ADDED
|
Binary file (3.31 kB). View file
|
|
|
core_logic/__pycache__/langchain_llm.cpython-310.pyc
ADDED
|
Binary file (1.43 kB). View file
|
|
|
core_logic/__pycache__/llm_client.cpython-310.pyc
ADDED
|
Binary file (2.69 kB). View file
|
|
|
core_logic/__pycache__/llm_config.cpython-310.pyc
ADDED
|
Binary file (1.41 kB). View file
|
|
|
core_logic/__pycache__/rewrite_tools.cpython-310.pyc
ADDED
|
Binary file (1.21 kB). View file
|
|
|
core_logic/__pycache__/video_pipeline.cpython-310.pyc
ADDED
|
Binary file (6.41 kB). View file
|
|
|
core_logic/__pycache__/video_schema.cpython-310.pyc
ADDED
|
Binary file (3.03 kB). View file
|
|
|
core_logic/chat_agent.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent-style chat for Marketeer using LangChain tools.
|
| 3 |
+
|
| 4 |
+
- Uses ChatHuggingFace from llm_config.get_local_chat_model()
|
| 5 |
+
- Uses rewrite / tone tools from rewrite_tools.py
|
| 6 |
+
- Implements a tiny tool-calling loop with .bind_tools() (no AgentExecutor).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any, Dict, List, Tuple, Union
|
| 10 |
+
|
| 11 |
+
from langchain_core.messages import (
|
| 12 |
+
AIMessage,
|
| 13 |
+
HumanMessage,
|
| 14 |
+
SystemMessage,
|
| 15 |
+
ToolMessage,
|
| 16 |
+
)
|
| 17 |
+
from langchain_core.tools import BaseTool
|
| 18 |
+
|
| 19 |
+
from core_logic.llm_config import get_local_chat_model
|
| 20 |
+
from core_logic.copy_pipeline import CopyRequest
|
| 21 |
+
from helpers.platform_styles import get_platform_style # <-- dataclass style
|
| 22 |
+
from core_logic.rewrite_tools import get_rewrite_tools
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
Message = Union[HumanMessage, AIMessage]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --------------------------------------------------------------------
|
| 29 |
+
# Helpers for platform style and history
|
| 30 |
+
# --------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_style_attr(style: Any, field: str, default: str = "") -> str:
|
| 34 |
+
"""
|
| 35 |
+
Safe attribute getter for PlatformStyle dataclass (or dict fallback).
|
| 36 |
+
|
| 37 |
+
This handles both:
|
| 38 |
+
- dataclass PlatformStyle (preferred)
|
| 39 |
+
- dict-like (in case of accidental mix)
|
| 40 |
+
"""
|
| 41 |
+
if style is None:
|
| 42 |
+
return default
|
| 43 |
+
|
| 44 |
+
# Dataclass / object path
|
| 45 |
+
if hasattr(style, field):
|
| 46 |
+
value = getattr(style, field)
|
| 47 |
+
return default if value is None else str(value)
|
| 48 |
+
|
| 49 |
+
# Dict path (just in case)
|
| 50 |
+
if isinstance(style, dict):
|
| 51 |
+
value = style.get(field, default)
|
| 52 |
+
return default if value is None else str(value)
|
| 53 |
+
|
| 54 |
+
return default
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _build_system_prompt(req: CopyRequest) -> str:
|
| 58 |
+
"""
|
| 59 |
+
Build a system instruction that explains:
|
| 60 |
+
- you are a marketing copywriter
|
| 61 |
+
- you know the campaign context
|
| 62 |
+
- you may optionally use tools to rewrite/edit
|
| 63 |
+
"""
|
| 64 |
+
# This comes from helpers.platform_styles and returns a PlatformStyle dataclass
|
| 65 |
+
style = get_platform_style(req.platform_name or "Instagram")
|
| 66 |
+
|
| 67 |
+
# Access attributes directly (NO dict-style indexing anywhere)
|
| 68 |
+
voice = getattr(style, "voice", "")
|
| 69 |
+
emoji_guideline = getattr(style, "emoji_guideline", "")
|
| 70 |
+
hashtag_guideline = getattr(style, "hashtag_guideline", "")
|
| 71 |
+
formatting_guideline = getattr(style, "formatting_guideline", "")
|
| 72 |
+
extra_notes = getattr(style, "extra_notes", "")
|
| 73 |
+
|
| 74 |
+
return f"""
|
| 75 |
+
You are Marketeer, an expert marketing copywriter.
|
| 76 |
+
|
| 77 |
+
You help users:
|
| 78 |
+
- write first-draft posts
|
| 79 |
+
- refine tone
|
| 80 |
+
- shorten or expand posts
|
| 81 |
+
- adapt copy across platforms
|
| 82 |
+
|
| 83 |
+
Campaign context:
|
| 84 |
+
- Brand: {req.brand}
|
| 85 |
+
- Product / offer: {req.product}
|
| 86 |
+
- Audience: {req.audience}
|
| 87 |
+
- Goal: {req.goal}
|
| 88 |
+
- Platform: {req.platform_name}
|
| 89 |
+
- Tone: {req.tone}
|
| 90 |
+
- CTA style: {req.cta_style}
|
| 91 |
+
- Extra context: {req.extra_context}
|
| 92 |
+
|
| 93 |
+
Platform style guidelines:
|
| 94 |
+
- Voice: {voice}
|
| 95 |
+
- Emoji usage: {emoji_guideline}
|
| 96 |
+
- Hashtags: {hashtag_guideline}
|
| 97 |
+
- Formatting: {formatting_guideline}
|
| 98 |
+
- Extra notes: {extra_notes}
|
| 99 |
+
|
| 100 |
+
You may have access to special tools that help you:
|
| 101 |
+
- adjust tone
|
| 102 |
+
- shorten or expand text
|
| 103 |
+
- remove or add emojis
|
| 104 |
+
- tweak style
|
| 105 |
+
|
| 106 |
+
When you respond:
|
| 107 |
+
- If the user clearly wants a simple answer, respond directly.
|
| 108 |
+
- If the user is asking to rewrite existing text (e.g. "shorten this",
|
| 109 |
+
"make it more professional", "remove emojis"), feel free to call tools
|
| 110 |
+
if they are available.
|
| 111 |
+
- Always return clean, user-ready copy (no JSON, no debug).
|
| 112 |
+
""".strip()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _build_message_history(history_pairs: List[List[str]]) -> List[Message]:
|
| 117 |
+
"""
|
| 118 |
+
Convert [[user, assistant], ...] into LangChain Human/AI messages.
|
| 119 |
+
"""
|
| 120 |
+
messages: List[Message] = []
|
| 121 |
+
for pair in history_pairs:
|
| 122 |
+
if not pair or len(pair) != 2:
|
| 123 |
+
continue
|
| 124 |
+
user_text, assistant_text = pair
|
| 125 |
+
if user_text:
|
| 126 |
+
messages.append(HumanMessage(content=user_text))
|
| 127 |
+
if assistant_text:
|
| 128 |
+
messages.append(AIMessage(content=assistant_text))
|
| 129 |
+
return messages
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _get_tool_map(tools: List[BaseTool]) -> Dict[str, BaseTool]:
|
| 133 |
+
"""
|
| 134 |
+
Convenience map: tool_name -> tool object.
|
| 135 |
+
"""
|
| 136 |
+
return {tool.name: tool for tool in tools}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# --------------------------------------------------------------------
|
| 140 |
+
# Main agent entry point
|
| 141 |
+
# --------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def agent_chat_turn(
|
| 145 |
+
req: CopyRequest,
|
| 146 |
+
user_message: str,
|
| 147 |
+
history_pairs: List[List[str]] | None = None,
|
| 148 |
+
) -> Tuple[str, str, list]:
|
| 149 |
+
...
|
| 150 |
+
history_pairs = history_pairs or []
|
| 151 |
+
|
| 152 |
+
# 1) Build base messages: "system" prompt as a HumanMessage + history + new user
|
| 153 |
+
instructions = _build_system_prompt(req)
|
| 154 |
+
|
| 155 |
+
# IMPORTANT: use HumanMessage here, not SystemMessage
|
| 156 |
+
system_msg = HumanMessage(content=instructions)
|
| 157 |
+
|
| 158 |
+
history_msgs = _build_message_history(history_pairs)
|
| 159 |
+
new_user_msg = HumanMessage(content=user_message)
|
| 160 |
+
|
| 161 |
+
messages: List[Union[Message, ToolMessage]] = (
|
| 162 |
+
[system_msg] + history_msgs + [new_user_msg]
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# 2) Prepare tools & model
|
| 167 |
+
tools: List[BaseTool] = get_rewrite_tools()
|
| 168 |
+
tool_map = _get_tool_map(tools)
|
| 169 |
+
|
| 170 |
+
llm = get_local_chat_model()
|
| 171 |
+
llm_with_tools = llm.bind_tools(tools)
|
| 172 |
+
|
| 173 |
+
# 3) First model call (decide whether to use tools)
|
| 174 |
+
ai_msg: AIMessage = llm_with_tools.invoke(messages)
|
| 175 |
+
raw_first = ai_msg.content or ""
|
| 176 |
+
|
| 177 |
+
# If the model does not request any tools, just return its answer
|
| 178 |
+
if not getattr(ai_msg, "tool_calls", None):
|
| 179 |
+
final_text = raw_first.strip()
|
| 180 |
+
return final_text, raw_first, []
|
| 181 |
+
|
| 182 |
+
# 4) Execute any requested tools
|
| 183 |
+
messages.append(ai_msg)
|
| 184 |
+
tool_messages: List[ToolMessage] = []
|
| 185 |
+
|
| 186 |
+
for tool_call in ai_msg.tool_calls:
|
| 187 |
+
tool_name = tool_call.get("name")
|
| 188 |
+
args = tool_call.get("args", {})
|
| 189 |
+
call_id = tool_call.get("id") or ""
|
| 190 |
+
|
| 191 |
+
tool = tool_map.get(tool_name)
|
| 192 |
+
if tool is None:
|
| 193 |
+
tool_output = f"Tool '{tool_name}' is not available."
|
| 194 |
+
else:
|
| 195 |
+
# LangChain tools usually implement .invoke()
|
| 196 |
+
try:
|
| 197 |
+
tool_output = tool.invoke(args)
|
| 198 |
+
except Exception as e:
|
| 199 |
+
tool_output = f"Tool '{tool_name}' failed with error: {e}"
|
| 200 |
+
|
| 201 |
+
tool_msg = ToolMessage(
|
| 202 |
+
content=str(tool_output),
|
| 203 |
+
tool_call_id=call_id,
|
| 204 |
+
)
|
| 205 |
+
tool_messages.append(tool_msg)
|
| 206 |
+
|
| 207 |
+
messages.extend(tool_messages)
|
| 208 |
+
|
| 209 |
+
# 5) Second model call: let the LLM see tool results and answer
|
| 210 |
+
final_ai: AIMessage = llm_with_tools.invoke(messages)
|
| 211 |
+
final_text = (final_ai.content or "").strip()
|
| 212 |
+
raw_second = final_ai.content or ""
|
| 213 |
+
|
| 214 |
+
audit: list = [] # reserved for tool call logs if you want later
|
| 215 |
+
|
| 216 |
+
return final_text, raw_second, audit
|
core_logic/chat_chain.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LangChain-based chat helper for copy generation.
|
| 3 |
+
|
| 4 |
+
We use:
|
| 5 |
+
- PromptTemplate from langchain_core
|
| 6 |
+
- ChatHuggingFace model from llm_config
|
| 7 |
+
- Simple chat history from the Gradio Chatbot (list of [user, assistant] pairs)
|
| 8 |
+
|
| 9 |
+
We DO NOT use SystemMessage, because the current model's chat template
|
| 10 |
+
does not support a "system" role. Instead, we fold all instructions and
|
| 11 |
+
campaign context into a single HumanMessage prompt, including platform
|
| 12 |
+
style guidelines (Phase 3).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import List, Tuple
|
| 16 |
+
|
| 17 |
+
from langchain_core.prompts import PromptTemplate
|
| 18 |
+
from langchain_core.messages import HumanMessage
|
| 19 |
+
|
| 20 |
+
from core_logic.llm_config import get_local_chat_model
|
| 21 |
+
from .copy_pipeline import CopyRequest
|
| 22 |
+
from helpers.platform_rules import (
|
| 23 |
+
PLATFORM_RULES,
|
| 24 |
+
DEFAULT_PLATFORM_NAME,
|
| 25 |
+
PlatformConfig,
|
| 26 |
+
get_platform_style,
|
| 27 |
+
)
|
| 28 |
+
from helpers.validators import validate_and_edit
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_platform_config(name: str) -> PlatformConfig:
|
| 32 |
+
if name in PLATFORM_RULES:
|
| 33 |
+
return PLATFORM_RULES[name]
|
| 34 |
+
return PLATFORM_RULES[DEFAULT_PLATFORM_NAME]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_chat_prompt_template() -> PromptTemplate:
|
| 38 |
+
"""
|
| 39 |
+
Template takes:
|
| 40 |
+
- brand, product, audience, goal, platform, tone, cta_style, extra_context
|
| 41 |
+
- char_cap
|
| 42 |
+
- style_voice, style_emoji_guideline, style_hashtag_guideline, style_length_guideline
|
| 43 |
+
- history (chat transcript as text)
|
| 44 |
+
- input (latest user message)
|
| 45 |
+
"""
|
| 46 |
+
template = """
|
| 47 |
+
You are an expert social media marketer.
|
| 48 |
+
You help refine and iterate on social media posts for {platform}.
|
| 49 |
+
|
| 50 |
+
Campaign context:
|
| 51 |
+
- Brand: {brand}
|
| 52 |
+
- Product/Offer: {product}
|
| 53 |
+
- Target audience: {audience}
|
| 54 |
+
- Campaign goal: {goal}
|
| 55 |
+
- Tone requested by user: {tone}
|
| 56 |
+
- Call-to-action style: {cta_style}
|
| 57 |
+
- Extra context from the user: {extra_context}
|
| 58 |
+
|
| 59 |
+
Platform style guidelines for {platform}:
|
| 60 |
+
- Voice and personality: {style_voice}
|
| 61 |
+
- Emojis: {style_emoji_guideline}
|
| 62 |
+
- Hashtags: {style_hashtag_guideline}
|
| 63 |
+
- Length: {style_length_guideline}
|
| 64 |
+
- Character limit: approximately {char_cap} characters.
|
| 65 |
+
|
| 66 |
+
Here is the conversation so far between you and the user
|
| 67 |
+
about this campaign:
|
| 68 |
+
|
| 69 |
+
{history}
|
| 70 |
+
|
| 71 |
+
Now the user says:
|
| 72 |
+
{input}
|
| 73 |
+
|
| 74 |
+
Your task:
|
| 75 |
+
- Follow the platform style guidelines and tone.
|
| 76 |
+
- Respect the character limit as much as reasonably possible.
|
| 77 |
+
- If the user asks to edit or adapt an existing post, transform it accordingly.
|
| 78 |
+
- Do NOT include explanations, analysis, or labels in your answer.
|
| 79 |
+
|
| 80 |
+
Respond with ONLY the post text or edited post text
|
| 81 |
+
the user asked for. Do not add any extra commentary.
|
| 82 |
+
"""
|
| 83 |
+
return PromptTemplate(
|
| 84 |
+
input_variables=[
|
| 85 |
+
"brand",
|
| 86 |
+
"product",
|
| 87 |
+
"audience",
|
| 88 |
+
"goal",
|
| 89 |
+
"platform",
|
| 90 |
+
"tone",
|
| 91 |
+
"cta_style",
|
| 92 |
+
"extra_context",
|
| 93 |
+
"char_cap",
|
| 94 |
+
"style_voice",
|
| 95 |
+
"style_emoji_guideline",
|
| 96 |
+
"style_hashtag_guideline",
|
| 97 |
+
"style_length_guideline",
|
| 98 |
+
"history",
|
| 99 |
+
"input",
|
| 100 |
+
],
|
| 101 |
+
template=template.strip(),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _format_history(history_pairs: List[Tuple[str, str]]) -> str:
|
| 106 |
+
"""
|
| 107 |
+
Convert list of (user, assistant) messages into a simple text transcript.
|
| 108 |
+
"""
|
| 109 |
+
if not history_pairs:
|
| 110 |
+
return "(No previous conversation yet.)"
|
| 111 |
+
|
| 112 |
+
lines = []
|
| 113 |
+
for u, a in history_pairs:
|
| 114 |
+
if u:
|
| 115 |
+
lines.append(f"User: {u}")
|
| 116 |
+
if a:
|
| 117 |
+
lines.append(f"Assistant: {a}")
|
| 118 |
+
return "\n".join(lines)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def chat_turn(
|
| 122 |
+
req: CopyRequest,
|
| 123 |
+
user_message: str,
|
| 124 |
+
history_pairs: List[Tuple[str, str]],
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
Run one chat turn:
|
| 128 |
+
|
| 129 |
+
- Uses LangChain PromptTemplate + ChatHuggingFace (via get_local_chat_model)
|
| 130 |
+
- Uses history_pairs (from Gradio Chatbot) as conversation history
|
| 131 |
+
- Applies platform style guidelines (Phase 3)
|
| 132 |
+
- Applies validators (banned terms, char caps, etc.)
|
| 133 |
+
- Returns final_text, raw_text, audit
|
| 134 |
+
"""
|
| 135 |
+
platform_cfg = _get_platform_config(req.platform_name)
|
| 136 |
+
style = get_platform_style(req.platform_name)
|
| 137 |
+
|
| 138 |
+
prompt_tmpl = build_chat_prompt_template()
|
| 139 |
+
history_text = _format_history(history_pairs)
|
| 140 |
+
|
| 141 |
+
# Build the full prompt string with context + style + history + latest user message
|
| 142 |
+
prompt_str = prompt_tmpl.format(
|
| 143 |
+
brand=req.brand or "",
|
| 144 |
+
product=req.product or "",
|
| 145 |
+
audience=req.audience or "",
|
| 146 |
+
goal=req.goal or "",
|
| 147 |
+
platform=style.get("name", req.platform_name or "Unknown platform"),
|
| 148 |
+
tone=req.tone or "friendly",
|
| 149 |
+
cta_style=req.cta_style or "soft",
|
| 150 |
+
extra_context=req.extra_context or "",
|
| 151 |
+
char_cap=str(platform_cfg.cap)
|
| 152 |
+
if hasattr(platform_cfg, "cap")
|
| 153 |
+
else str(getattr(platform_cfg, "char_cap", 280)),
|
| 154 |
+
style_voice=style.get("voice", ""),
|
| 155 |
+
style_emoji_guideline=style.get("emoji_guideline", ""),
|
| 156 |
+
style_hashtag_guideline=style.get("hashtag_guideline", ""),
|
| 157 |
+
style_length_guideline=style.get("length_guideline", ""),
|
| 158 |
+
history=history_text,
|
| 159 |
+
input=user_message,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Call the ChatHuggingFace model with a single HumanMessage
|
| 163 |
+
chat_model = get_local_chat_model()
|
| 164 |
+
ai_msg = chat_model.invoke([HumanMessage(content=prompt_str)])
|
| 165 |
+
raw_text = ai_msg.content
|
| 166 |
+
|
| 167 |
+
# Apply your existing validators (banned phrases, length, etc.)
|
| 168 |
+
final_text, audit = validate_and_edit(raw_text, platform_cfg)
|
| 169 |
+
|
| 170 |
+
return final_text, raw_text, audit
|
core_logic/copy_pipeline.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Copy generation pipeline for Marketeer.
|
| 3 |
+
|
| 4 |
+
This wraps the low-level LLM client and the helper utilities
|
| 5 |
+
into a single `generate_copy` function that other parts of
|
| 6 |
+
the app (like the Gradio UI) can call.
|
| 7 |
+
|
| 8 |
+
High level:
|
| 9 |
+
- Build a structured prompt using the provided context.
|
| 10 |
+
- Call the LLM to generate text.
|
| 11 |
+
- Run validators (banned terms, length caps, etc.).
|
| 12 |
+
- Return raw text, final text, and an audit log.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Dict, Any, List, Tuple
|
| 17 |
+
|
| 18 |
+
from .llm_client import generate_text
|
| 19 |
+
from helpers.platform_rules import PLATFORM_RULES, DEFAULT_PLATFORM_NAME, PlatformConfig
|
| 20 |
+
from helpers.validators import validate_and_edit
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class CopyRequest:
|
| 25 |
+
brand: str
|
| 26 |
+
product: str
|
| 27 |
+
audience: str
|
| 28 |
+
goal: str
|
| 29 |
+
platform_name: str
|
| 30 |
+
tone: str
|
| 31 |
+
cta_style: str
|
| 32 |
+
extra_context: str = ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class CopyResponse:
|
| 37 |
+
platform: str
|
| 38 |
+
raw: str
|
| 39 |
+
final: str
|
| 40 |
+
cap: int
|
| 41 |
+
audit: List[Dict[str, Any]]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _get_platform_config(name: str) -> PlatformConfig:
|
| 45 |
+
"""Return a known PlatformConfig or default to Instagram."""
|
| 46 |
+
if name in PLATFORM_RULES:
|
| 47 |
+
return PLATFORM_RULES[name]
|
| 48 |
+
# allow simple aliases like "X" or "Twitter/X" later if you want
|
| 49 |
+
return PLATFORM_RULES[DEFAULT_PLATFORM_NAME]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _build_prompt(req: CopyRequest, platform: PlatformConfig) -> str:
|
| 53 |
+
"""
|
| 54 |
+
Build a reasonably structured prompt for the LLM.
|
| 55 |
+
|
| 56 |
+
This is intentionally simple for now; you can make it
|
| 57 |
+
fancier later (add examples, formatting, etc.).
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
lines = [
|
| 61 |
+
f"You are an expert social media marketer.",
|
| 62 |
+
f"Write a single post for {platform.name}.",
|
| 63 |
+
"",
|
| 64 |
+
f"Brand: {req.brand}",
|
| 65 |
+
f"Product/Offer: {req.product}",
|
| 66 |
+
f"Target audience: {req.audience}",
|
| 67 |
+
f"Campaign goal: {req.goal}",
|
| 68 |
+
f"Tone: {req.tone}",
|
| 69 |
+
f"Call-to-action style: {req.cta_style}",
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
if req.extra_context.strip():
|
| 73 |
+
lines.append(f"Extra context: {req.extra_context.strip()}")
|
| 74 |
+
|
| 75 |
+
lines.append("")
|
| 76 |
+
lines.append(
|
| 77 |
+
f"Keep the copy within approximately {platform.char_cap} characters, "
|
| 78 |
+
f"and make it engaging but natural."
|
| 79 |
+
)
|
| 80 |
+
lines.append("Do not include explanations, just the post text itself.")
|
| 81 |
+
|
| 82 |
+
return "\n".join(lines)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def generate_copy(req: CopyRequest) -> CopyResponse:
|
| 86 |
+
"""
|
| 87 |
+
Main entry point for marketing copy generation.
|
| 88 |
+
|
| 89 |
+
1) Resolve platform config.
|
| 90 |
+
2) Build a prompt.
|
| 91 |
+
3) Call the LLM.
|
| 92 |
+
4) Run validators and collect audit.
|
| 93 |
+
5) Return structured response.
|
| 94 |
+
"""
|
| 95 |
+
platform = _get_platform_config(req.platform_name)
|
| 96 |
+
|
| 97 |
+
prompt = _build_prompt(req, platform)
|
| 98 |
+
|
| 99 |
+
raw_text = generate_text(
|
| 100 |
+
prompt=prompt,
|
| 101 |
+
max_new_tokens=256,
|
| 102 |
+
temperature=0.8,
|
| 103 |
+
top_p=0.9,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
final_text, audit = validate_and_edit(raw_text, platform)
|
| 107 |
+
|
| 108 |
+
return CopyResponse(
|
| 109 |
+
platform=platform.name,
|
| 110 |
+
raw=raw_text,
|
| 111 |
+
final=final_text,
|
| 112 |
+
cap=platform.char_cap,
|
| 113 |
+
audit=audit,
|
| 114 |
+
)
|
core_logic/langchain_llm.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LangChain-compatible LLM wrapper around our existing generate_text().
|
| 3 |
+
|
| 4 |
+
This lets us use LangChain chains, prompts, and memory
|
| 5 |
+
without changing the underlying HF model logic.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from langchain_core.language_models.llms import LLM
|
| 11 |
+
|
| 12 |
+
from .llm_client import generate_text
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MarketeerLLM(LLM):
|
| 16 |
+
"""
|
| 17 |
+
Minimal LangChain LLM that just calls our generate_text() helper.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def _call(
|
| 21 |
+
self,
|
| 22 |
+
prompt: str,
|
| 23 |
+
stop: Optional[List[str]] = None,
|
| 24 |
+
**kwargs: Any,
|
| 25 |
+
) -> str:
|
| 26 |
+
# You can pass temp/top_p via kwargs if you want, or keep fixed config.
|
| 27 |
+
text = generate_text(
|
| 28 |
+
prompt=prompt,
|
| 29 |
+
max_new_tokens=kwargs.get("max_new_tokens", 256),
|
| 30 |
+
temperature=kwargs.get("temperature", 0.8),
|
| 31 |
+
top_p=kwargs.get("top_p", 0.9),
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Apply stop tokens if provided
|
| 35 |
+
if stop:
|
| 36 |
+
for s in stop:
|
| 37 |
+
if s in text:
|
| 38 |
+
text = text.split(s)[0]
|
| 39 |
+
break
|
| 40 |
+
|
| 41 |
+
return text.strip()
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def _llm_type(self) -> str:
|
| 45 |
+
return "marketeer_llm"
|
core_logic/llm_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM client for Marketeer.
|
| 3 |
+
|
| 4 |
+
This module exposes a single function:
|
| 5 |
+
|
| 6 |
+
generate_text(prompt: str, max_new_tokens: int = 256, temperature: float = 0.8, top_p: float = 0.9) -> str
|
| 7 |
+
|
| 8 |
+
Internally it:
|
| 9 |
+
- Loads the tokenizer & model once.
|
| 10 |
+
- Uses MODEL_ID from environment (or a sensible default).
|
| 11 |
+
- Lets `device_map="auto"` handle GPU/CPU placement when CUDA is available.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ----- Configuration -----
|
| 22 |
+
|
| 23 |
+
DEFAULT_MODEL_ID = "google/gemma-2-2b-it"
|
| 24 |
+
_MODEL_ID = os.getenv("MODEL_ID", DEFAULT_MODEL_ID)
|
| 25 |
+
|
| 26 |
+
_tokenizer: Optional[AutoTokenizer] = None
|
| 27 |
+
_model: Optional[AutoModelForCausalLM] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _load_model_if_needed():
|
| 31 |
+
"""Lazy-load tokenizer and model into global variables."""
|
| 32 |
+
global _tokenizer, _model
|
| 33 |
+
|
| 34 |
+
if _tokenizer is not None and _model is not None:
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
has_cuda = torch.cuda.is_available()
|
| 38 |
+
|
| 39 |
+
# bfloat16/float16 on GPU, float32 on CPU
|
| 40 |
+
if has_cuda:
|
| 41 |
+
dtype = torch.bfloat16
|
| 42 |
+
device_map = "auto" # let accelerate handle offload across GPU/CPU
|
| 43 |
+
else:
|
| 44 |
+
dtype = torch.float32
|
| 45 |
+
device_map = None
|
| 46 |
+
|
| 47 |
+
_tokenizer = AutoTokenizer.from_pretrained(_MODEL_ID)
|
| 48 |
+
|
| 49 |
+
_model = AutoModelForCausalLM.from_pretrained(
|
| 50 |
+
_MODEL_ID,
|
| 51 |
+
dtype=dtype, # use dtype instead of deprecated torch_dtype
|
| 52 |
+
device_map=device_map,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Ensure pad token exists (some causal models don't define it)
|
| 56 |
+
if _tokenizer.pad_token is None:
|
| 57 |
+
_tokenizer.pad_token = _tokenizer.eos_token
|
| 58 |
+
|
| 59 |
+
_model.eval() # IMPORTANT: no _model.to(...) here
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def generate_text(
|
| 63 |
+
prompt: str,
|
| 64 |
+
max_new_tokens: int = 256,
|
| 65 |
+
temperature: float = 0.8,
|
| 66 |
+
top_p: float = 0.9,
|
| 67 |
+
) -> str:
|
| 68 |
+
"""
|
| 69 |
+
Generate text from the model given a plain prompt.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
prompt: The input text prompt.
|
| 73 |
+
max_new_tokens: Maximum number of new tokens to generate.
|
| 74 |
+
temperature: Sampling temperature (>1 = more random, <1 = more focused).
|
| 75 |
+
top_p: Nucleus sampling probability mass.
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
The generated text (prompt excluded where possible).
|
| 79 |
+
"""
|
| 80 |
+
if not isinstance(prompt, str):
|
| 81 |
+
raise TypeError("prompt must be a string")
|
| 82 |
+
|
| 83 |
+
cleaned_prompt = prompt.strip()
|
| 84 |
+
if not cleaned_prompt:
|
| 85 |
+
raise ValueError("prompt is empty after stripping whitespace")
|
| 86 |
+
|
| 87 |
+
_load_model_if_needed()
|
| 88 |
+
assert _tokenizer is not None
|
| 89 |
+
assert _model is not None
|
| 90 |
+
|
| 91 |
+
# DO NOT .to(device) here; accelerate handles device placement for us
|
| 92 |
+
inputs = _tokenizer(
|
| 93 |
+
cleaned_prompt,
|
| 94 |
+
return_tensors="pt",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
with torch.no_grad():
|
| 98 |
+
output_ids = _model.generate(
|
| 99 |
+
**inputs,
|
| 100 |
+
max_new_tokens=max_new_tokens,
|
| 101 |
+
do_sample=True,
|
| 102 |
+
temperature=temperature,
|
| 103 |
+
top_p=top_p,
|
| 104 |
+
pad_token_id=_tokenizer.pad_token_id,
|
| 105 |
+
eos_token_id=_tokenizer.eos_token_id,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
full_text = _tokenizer.decode(
|
| 109 |
+
output_ids[0],
|
| 110 |
+
skip_special_tokens=True,
|
| 111 |
+
clean_up_tokenization_spaces=True,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Strip echoed prompt if present
|
| 115 |
+
if full_text.startswith(cleaned_prompt):
|
| 116 |
+
generated = full_text[len(cleaned_prompt):].lstrip()
|
| 117 |
+
else:
|
| 118 |
+
generated = full_text
|
| 119 |
+
|
| 120 |
+
return generated.strip()
|
core_logic/llm_config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM configuration for Marketeer.
|
| 3 |
+
|
| 4 |
+
This module exposes helpers to get a LangChain ChatHuggingFace model,
|
| 5 |
+
backed by a local Hugging Face pipeline (for development).
|
| 6 |
+
|
| 7 |
+
Later, we can add a get_endpoint_chat_model() for HF Inference API.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
from langchain_huggingface import ChatHuggingFace, HuggingFacePipeline
|
| 13 |
+
from transformers import BitsAndBytesConfig
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
MODEL_ID = "google/gemma-2-2b-it" # <-- change if you're using a different repo
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@lru_cache(maxsize=1)
|
| 20 |
+
def get_local_chat_model() -> ChatHuggingFace:
|
| 21 |
+
"""
|
| 22 |
+
Return a singleton ChatHuggingFace model running locally via transformers pipeline.
|
| 23 |
+
|
| 24 |
+
Uses 4-bit quantization to fit comfortably on a 6GB RTX 3060.
|
| 25 |
+
"""
|
| 26 |
+
quant_config = BitsAndBytesConfig(
|
| 27 |
+
load_in_4bit=True,
|
| 28 |
+
bnb_4bit_quant_type="nf4",
|
| 29 |
+
bnb_4bit_compute_dtype="float16",
|
| 30 |
+
bnb_4bit_use_double_quant=True,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# HuggingFacePipeline wraps a transformers.pipeline under the hood
|
| 34 |
+
base_llm = HuggingFacePipeline.from_model_id(
|
| 35 |
+
model_id=MODEL_ID,
|
| 36 |
+
task="text-generation",
|
| 37 |
+
pipeline_kwargs=dict(
|
| 38 |
+
max_new_tokens=256,
|
| 39 |
+
do_sample=True,
|
| 40 |
+
temperature=0.8,
|
| 41 |
+
top_p=0.9,
|
| 42 |
+
return_full_text=False, # we only want the generated continuation
|
| 43 |
+
),
|
| 44 |
+
model_kwargs={
|
| 45 |
+
"quantization_config": quant_config,
|
| 46 |
+
},
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
chat_model = ChatHuggingFace(llm=base_llm)
|
| 50 |
+
return chat_model
|
core_logic/rewrite_tools.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from langchain_core.tools import tool
|
| 3 |
+
from langchain_core.tools import BaseTool
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@tool
|
| 7 |
+
def shorten_copy(text: str, max_words: int = 40) -> str:
|
| 8 |
+
"""Shorten the given marketing copy while preserving core meaning and CTA."""
|
| 9 |
+
# simple baseline implementation; the LLM will often rewrite again
|
| 10 |
+
words = text.split()
|
| 11 |
+
if len(words) <= max_words:
|
| 12 |
+
return text
|
| 13 |
+
return " ".join(words[:max_words]) + "..."
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@tool
|
| 17 |
+
def remove_emojis(text: str) -> str:
|
| 18 |
+
"""Remove emojis and overly playful styling from the copy."""
|
| 19 |
+
# naive implementation – works fine as a starting point
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
emoji_pattern = re.compile(
|
| 23 |
+
"["
|
| 24 |
+
"\U0001F600-\U0001F64F"
|
| 25 |
+
"\U0001F300-\U0001F5FF"
|
| 26 |
+
"\U0001F680-\U0001F6FF"
|
| 27 |
+
"\U0001F1E0-\U0001F1FF"
|
| 28 |
+
"]+",
|
| 29 |
+
flags=re.UNICODE,
|
| 30 |
+
)
|
| 31 |
+
no_emoji = emoji_pattern.sub("", text)
|
| 32 |
+
return " ".join(no_emoji.split())
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_rewrite_tools() -> List[BaseTool]:
|
| 36 |
+
"""
|
| 37 |
+
Return the list of tools the agent can use.
|
| 38 |
+
Add tone_shift, expand, etc. here over time.
|
| 39 |
+
"""
|
| 40 |
+
return [shorten_copy, remove_emojis]
|
core_logic/video_pipeline.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core_logic/video_pipeline.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Video script generation pipeline for Marketeer.
|
| 5 |
+
|
| 6 |
+
Phase 5: Use a structured Pydantic schema (VideoScriptResponse)
|
| 7 |
+
while keeping the external behaviour compatible with the existing UI.
|
| 8 |
+
|
| 9 |
+
High-level flow:
|
| 10 |
+
1. Build a simple beat plan based on blueprint + duration.
|
| 11 |
+
2. For each beat, ask the LLM for a JSON block with:
|
| 12 |
+
- voiceover
|
| 13 |
+
- on_screen
|
| 14 |
+
- shots
|
| 15 |
+
- broll
|
| 16 |
+
- captions
|
| 17 |
+
3. Parse JSON into VideoBeat models.
|
| 18 |
+
4. Return a VideoScriptResponse (plan + warnings).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from typing import List, Dict, Any
|
| 26 |
+
|
| 27 |
+
from core_logic.llm_client import generate_text
|
| 28 |
+
from core_logic.video_schema import (
|
| 29 |
+
VideoBeat,
|
| 30 |
+
VideoPlan,
|
| 31 |
+
VideoScriptResponse,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --------------------------------------------------------------------
|
| 36 |
+
# Request object coming from UI
|
| 37 |
+
# --------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class VideoRequest:
|
| 42 |
+
brand: str
|
| 43 |
+
product: str
|
| 44 |
+
audience: str
|
| 45 |
+
goal: str
|
| 46 |
+
blueprint_name: str
|
| 47 |
+
duration_sec: int
|
| 48 |
+
platform_name: str
|
| 49 |
+
style: str
|
| 50 |
+
extra_context: str = ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------
|
| 54 |
+
# Internal helpers: plan building & prompting
|
| 55 |
+
# --------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _build_basic_plan(req: VideoRequest) -> VideoPlan:
|
| 59 |
+
"""
|
| 60 |
+
Build a very simple beat plan based on blueprint and duration.
|
| 61 |
+
|
| 62 |
+
Right now we keep this deterministic and lightweight.
|
| 63 |
+
Later, you can make this itself LLM-driven if you want.
|
| 64 |
+
"""
|
| 65 |
+
total = max(req.duration_sec, 5)
|
| 66 |
+
blueprint = (req.blueprint_name or "short_ad").lower()
|
| 67 |
+
|
| 68 |
+
if blueprint == "short_ad":
|
| 69 |
+
# 3-beat: Hook -> Product -> CTA
|
| 70 |
+
beats_meta = [
|
| 71 |
+
("Hook / Problem", "Hook viewer, show the pain or context.", 0.0, total * 0.33),
|
| 72 |
+
("Product Moment", "Show product solving the problem.", total * 0.33, total * 0.66),
|
| 73 |
+
("CTA / Finish", "Wrap up and clear CTA.", total * 0.66, total),
|
| 74 |
+
]
|
| 75 |
+
elif blueprint == "ugc_review":
|
| 76 |
+
# 4-beat: Intro -> Problem -> Experience -> Recommendation
|
| 77 |
+
beats_meta = [
|
| 78 |
+
("Intro / Self", "Introduce the speaker as a real user.", 0.0, total * 0.25),
|
| 79 |
+
("Problem", "Describe the problem or frustration.", total * 0.25, total * 0.5),
|
| 80 |
+
("Experience", "Explain how using the product felt / helped.", total * 0.5, total * 0.75),
|
| 81 |
+
("Recommendation", "Recommend the product and invite viewer to try.", total * 0.75, total),
|
| 82 |
+
]
|
| 83 |
+
else: # how_to or fallback
|
| 84 |
+
# 4-beat: Hook -> Step(s) -> Result -> CTA
|
| 85 |
+
beats_meta = [
|
| 86 |
+
("Hook / Promise", "Hook viewer and promise what they will learn.", 0.0, total * 0.25),
|
| 87 |
+
("Step-by-step (1)", "Show the first main step.", total * 0.25, total * 0.5),
|
| 88 |
+
("Step-by-step (2)", "Show the second main step or refinement.", total * 0.5, total * 0.75),
|
| 89 |
+
("Result / CTA", "Show final outcome and clear CTA.", total * 0.75, total),
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
beats: List[VideoBeat] = []
|
| 93 |
+
for idx, (title, goal, t_start, t_end) in enumerate(beats_meta):
|
| 94 |
+
beats.append(
|
| 95 |
+
VideoBeat(
|
| 96 |
+
beat_index=idx,
|
| 97 |
+
title=title,
|
| 98 |
+
goal=goal,
|
| 99 |
+
t_start=float(round(t_start, 2)),
|
| 100 |
+
t_end=float(round(t_end, 2)),
|
| 101 |
+
voiceover="", # to be filled by LLM
|
| 102 |
+
on_screen="", # to be filled by LLM
|
| 103 |
+
shots=[],
|
| 104 |
+
broll=[],
|
| 105 |
+
captions=[],
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
plan = VideoPlan(
|
| 110 |
+
blueprint_name=req.blueprint_name,
|
| 111 |
+
duration_sec=total,
|
| 112 |
+
platform_name=req.platform_name,
|
| 113 |
+
style=req.style,
|
| 114 |
+
beats=beats,
|
| 115 |
+
)
|
| 116 |
+
return plan
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _build_beat_prompt(req: VideoRequest, plan: VideoPlan, beat: VideoBeat) -> str:
|
| 120 |
+
"""
|
| 121 |
+
Build an instruction to generate **one beat** as a JSON object.
|
| 122 |
+
"""
|
| 123 |
+
return f"""
|
| 124 |
+
You are helping create a short-form marketing video script.
|
| 125 |
+
|
| 126 |
+
Brand: {req.brand}
|
| 127 |
+
Product: {req.product}
|
| 128 |
+
Audience: {req.audience}
|
| 129 |
+
Campaign goal: {req.goal}
|
| 130 |
+
Platform: {req.platform_name}
|
| 131 |
+
Overall style: {req.style}
|
| 132 |
+
Extra context: {req.extra_context}
|
| 133 |
+
|
| 134 |
+
We are currently working on one beat of the video:
|
| 135 |
+
|
| 136 |
+
- Blueprint: {plan.blueprint_name}
|
| 137 |
+
- Beat index: {beat.beat_index}
|
| 138 |
+
- Beat title: {beat.title}
|
| 139 |
+
- Beat goal: {beat.goal}
|
| 140 |
+
- Start time: {beat.t_start} seconds
|
| 141 |
+
- End time: {beat.t_end} seconds
|
| 142 |
+
|
| 143 |
+
Return **only** a JSON object (no markdown, no backticks) with this shape:
|
| 144 |
+
|
| 145 |
+
{{
|
| 146 |
+
"voiceover": "string, the spoken line(s) for this beat",
|
| 147 |
+
"on_screen": "string, short text shown on screen",
|
| 148 |
+
"shots": ["list of camera shot ideas, strings"],
|
| 149 |
+
"broll": ["optional list of B-roll ideas, strings"],
|
| 150 |
+
"captions": ["optional list of caption lines, strings"]
|
| 151 |
+
}}
|
| 152 |
+
|
| 153 |
+
The voiceover should match the platform and style, and help achieve the beat goal.
|
| 154 |
+
Keep it concise but vivid.
|
| 155 |
+
""".strip()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _extract_json_from_response(raw: str) -> Dict[str, Any]:
|
| 159 |
+
"""
|
| 160 |
+
Try to extract a JSON object from the LLM response.
|
| 161 |
+
|
| 162 |
+
If it's already plain JSON, parse that.
|
| 163 |
+
If it's inside a markdown ```json block, extract the inner part.
|
| 164 |
+
Raises ValueError if parsing fails.
|
| 165 |
+
"""
|
| 166 |
+
text = raw.strip()
|
| 167 |
+
|
| 168 |
+
# Common case: LLM wraps in ```json ... ```
|
| 169 |
+
if "```" in text:
|
| 170 |
+
# Take the content between the first pair of ``` blocks
|
| 171 |
+
parts = text.split("```")
|
| 172 |
+
# Expected pattern: ["", "json\\n{...}", ""]
|
| 173 |
+
if len(parts) >= 3:
|
| 174 |
+
candidate = parts[1]
|
| 175 |
+
# Strip a leading "json" or "JSON" line
|
| 176 |
+
candidate = candidate.lstrip().split("\n", 1)
|
| 177 |
+
if len(candidate) == 2 and candidate[0].lower() in ("json", "json:"):
|
| 178 |
+
text = candidate[1].strip()
|
| 179 |
+
else:
|
| 180 |
+
text = "\n".join(candidate).strip()
|
| 181 |
+
|
| 182 |
+
return json.loads(text)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# --------------------------------------------------------------------
|
| 186 |
+
# Public API: generate_video_script
|
| 187 |
+
# --------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def generate_video_script(
|
| 191 |
+
req: VideoRequest,
|
| 192 |
+
debug_first: bool = False,
|
| 193 |
+
) -> VideoScriptResponse:
|
| 194 |
+
"""
|
| 195 |
+
Main entry point used by the UI.
|
| 196 |
+
|
| 197 |
+
Generates a structured VideoScriptResponse (plan + warnings). The
|
| 198 |
+
UI can still access:
|
| 199 |
+
resp.plan
|
| 200 |
+
resp.beats (alias for resp.plan.beats)
|
| 201 |
+
resp.warnings
|
| 202 |
+
"""
|
| 203 |
+
plan = _build_basic_plan(req)
|
| 204 |
+
warnings: List[str] = []
|
| 205 |
+
beats_out: List[VideoBeat] = []
|
| 206 |
+
|
| 207 |
+
for idx, beat in enumerate(plan.beats):
|
| 208 |
+
prompt = _build_beat_prompt(req, plan, beat)
|
| 209 |
+
|
| 210 |
+
raw = generate_text(
|
| 211 |
+
prompt=prompt,
|
| 212 |
+
max_new_tokens=256,
|
| 213 |
+
temperature=0.7,
|
| 214 |
+
top_p=0.9,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if debug_first and idx == 0:
|
| 218 |
+
print("=== RAW FIRST BEAT RESPONSE ===")
|
| 219 |
+
print(raw)
|
| 220 |
+
print("=" * 32)
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
data = _extract_json_from_response(raw)
|
| 224 |
+
# Merge structured info into the beat
|
| 225 |
+
beat_updated = VideoBeat(
|
| 226 |
+
beat_index=beat.beat_index,
|
| 227 |
+
title=beat.title,
|
| 228 |
+
goal=beat.goal,
|
| 229 |
+
t_start=beat.t_start,
|
| 230 |
+
t_end=beat.t_end,
|
| 231 |
+
voiceover=str(data.get("voiceover", "")).strip(),
|
| 232 |
+
on_screen=str(data.get("on_screen", "")).strip(),
|
| 233 |
+
shots=list(data.get("shots", []) or []),
|
| 234 |
+
broll=list(data.get("broll", []) or []),
|
| 235 |
+
captions=list(data.get("captions", []) or []),
|
| 236 |
+
)
|
| 237 |
+
beats_out.append(beat_updated)
|
| 238 |
+
except Exception as e:
|
| 239 |
+
warnings.append(
|
| 240 |
+
f"Beat {beat.beat_index}: failed to parse JSON from model response ({e})."
|
| 241 |
+
)
|
| 242 |
+
# Fallback: keep the original beat with generic placeholders
|
| 243 |
+
beats_out.append(
|
| 244 |
+
VideoBeat(
|
| 245 |
+
beat_index=beat.beat_index,
|
| 246 |
+
title=beat.title,
|
| 247 |
+
goal=beat.goal,
|
| 248 |
+
t_start=beat.t_start,
|
| 249 |
+
t_end=beat.t_end,
|
| 250 |
+
voiceover="",
|
| 251 |
+
on_screen="",
|
| 252 |
+
shots=[],
|
| 253 |
+
broll=[],
|
| 254 |
+
captions=[],
|
| 255 |
+
)
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# Construct final structured response
|
| 259 |
+
final_plan = VideoPlan(
|
| 260 |
+
blueprint_name=plan.blueprint_name,
|
| 261 |
+
duration_sec=plan.duration_sec,
|
| 262 |
+
platform_name=plan.platform_name,
|
| 263 |
+
style=plan.style,
|
| 264 |
+
beats=beats_out,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
resp = VideoScriptResponse(
|
| 268 |
+
plan=final_plan,
|
| 269 |
+
warnings=warnings,
|
| 270 |
+
)
|
| 271 |
+
return resp
|
core_logic/video_schema.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core_logic/video_schema.py
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class VideoBeat(BaseModel):
|
| 8 |
+
"""Single beat in the short-form video script."""
|
| 9 |
+
beat_index: int = Field(
|
| 10 |
+
...,
|
| 11 |
+
description="Zero-based index of this beat in the sequence.",
|
| 12 |
+
)
|
| 13 |
+
title: str = Field(
|
| 14 |
+
...,
|
| 15 |
+
description="Short title / label for this beat (e.g., 'Hook', 'Product Close-up').",
|
| 16 |
+
)
|
| 17 |
+
goal: str = Field(
|
| 18 |
+
...,
|
| 19 |
+
description="What this beat is trying to achieve (hook, social proof, CTA, etc.).",
|
| 20 |
+
)
|
| 21 |
+
t_start: float = Field(
|
| 22 |
+
...,
|
| 23 |
+
description="Approximate start time in seconds from the beginning of the video.",
|
| 24 |
+
)
|
| 25 |
+
t_end: float = Field(
|
| 26 |
+
...,
|
| 27 |
+
description="Approximate end time in seconds from the beginning of the video.",
|
| 28 |
+
)
|
| 29 |
+
voiceover: str = Field(
|
| 30 |
+
...,
|
| 31 |
+
description="Suggested voiceover line(s) for this beat.",
|
| 32 |
+
)
|
| 33 |
+
on_screen: str = Field(
|
| 34 |
+
...,
|
| 35 |
+
description="Short on-screen text / caption for this beat.",
|
| 36 |
+
)
|
| 37 |
+
shots: List[str] = Field(
|
| 38 |
+
default_factory=list,
|
| 39 |
+
description="List of camera shots / visuals in this beat.",
|
| 40 |
+
)
|
| 41 |
+
broll: List[str] = Field(
|
| 42 |
+
default_factory=list,
|
| 43 |
+
description="Optional B-roll ideas for this beat.",
|
| 44 |
+
)
|
| 45 |
+
captions: List[str] = Field(
|
| 46 |
+
default_factory=list,
|
| 47 |
+
description="Suggested caption lines or overlays.",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class VideoPlan(BaseModel):
|
| 52 |
+
"""High-level plan for the entire video."""
|
| 53 |
+
blueprint_name: str = Field(
|
| 54 |
+
...,
|
| 55 |
+
description="Name of the blueprint used (e.g., 'short_ad', 'ugc_review', 'how_to').",
|
| 56 |
+
)
|
| 57 |
+
duration_sec: int = Field(
|
| 58 |
+
...,
|
| 59 |
+
description="Total target duration of the video in seconds.",
|
| 60 |
+
)
|
| 61 |
+
platform_name: str = Field(
|
| 62 |
+
...,
|
| 63 |
+
description="Target platform label (e.g., 'Instagram Reels', 'YouTube Shorts').",
|
| 64 |
+
)
|
| 65 |
+
style: str = Field(
|
| 66 |
+
...,
|
| 67 |
+
description="Overall style (e.g., 'warm and energetic').",
|
| 68 |
+
)
|
| 69 |
+
beats: List[VideoBeat] = Field(
|
| 70 |
+
default_factory=list,
|
| 71 |
+
description="List of beats that make up this video.",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class VideoScriptResponse(BaseModel):
|
| 76 |
+
"""
|
| 77 |
+
Full structured response used by the app and UI.
|
| 78 |
+
"""
|
| 79 |
+
plan: VideoPlan = Field(
|
| 80 |
+
...,
|
| 81 |
+
description="High-level plan metadata and beat list.",
|
| 82 |
+
)
|
| 83 |
+
warnings: List[str] = Field(
|
| 84 |
+
default_factory=list,
|
| 85 |
+
description="Any warnings about parsing, timing, or beat structure.",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def beats(self) -> List[VideoBeat]:
|
| 90 |
+
"""
|
| 91 |
+
Backwards-compatible alias so older code can still do resp.beats.
|
| 92 |
+
Internally, beats live on resp.plan.beats.
|
| 93 |
+
"""
|
| 94 |
+
return self.plan.beats
|
helpers/__init__.py
ADDED
|
File without changes
|
helpers/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (151 Bytes). View file
|
|
|
helpers/__pycache__/blueprints.cpython-310.pyc
ADDED
|
Binary file (3.21 kB). View file
|
|
|
helpers/__pycache__/json_utils.cpython-310.pyc
ADDED
|
Binary file (1.93 kB). View file
|
|
|
helpers/__pycache__/platform_rules.cpython-310.pyc
ADDED
|
Binary file (3.07 kB). View file
|
|
|
helpers/__pycache__/platform_styles.cpython-310.pyc
ADDED
|
Binary file (3.2 kB). View file
|
|
|
helpers/__pycache__/validators.cpython-310.pyc
ADDED
|
Binary file (1.88 kB). View file
|
|
|
helpers/blueprints.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video blueprints for Marketeer.
|
| 3 |
+
|
| 4 |
+
These define high-level structures (beats) for different
|
| 5 |
+
short-form video types like short ads, UGC reviews, and how-tos.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import List, Dict
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class BeatTemplate:
|
| 14 |
+
id: str
|
| 15 |
+
title: str
|
| 16 |
+
goal: str
|
| 17 |
+
weight: float # relative share of total duration (sum of weights ≈ 1.0)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class Blueprint:
|
| 22 |
+
name: str
|
| 23 |
+
description: str
|
| 24 |
+
beats: List[BeatTemplate]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _short_ad() -> Blueprint:
|
| 28 |
+
beats = [
|
| 29 |
+
BeatTemplate(
|
| 30 |
+
id="hook",
|
| 31 |
+
title="Hook",
|
| 32 |
+
goal="Grab attention in the first second and stop the scroll.",
|
| 33 |
+
weight=0.2,
|
| 34 |
+
),
|
| 35 |
+
BeatTemplate(
|
| 36 |
+
id="problem",
|
| 37 |
+
title="Problem",
|
| 38 |
+
goal="Show the pain point the viewer feels right now.",
|
| 39 |
+
weight=0.2,
|
| 40 |
+
),
|
| 41 |
+
BeatTemplate(
|
| 42 |
+
id="solution",
|
| 43 |
+
title="Solution",
|
| 44 |
+
goal="Introduce the product as the clear solution.",
|
| 45 |
+
weight=0.3,
|
| 46 |
+
),
|
| 47 |
+
BeatTemplate(
|
| 48 |
+
id="proof",
|
| 49 |
+
title="Proof",
|
| 50 |
+
goal="Show quick proof: results, social proof, or credibility.",
|
| 51 |
+
weight=0.2,
|
| 52 |
+
),
|
| 53 |
+
BeatTemplate(
|
| 54 |
+
id="cta",
|
| 55 |
+
title="Call to Action",
|
| 56 |
+
goal="Give a clear, simple next step.",
|
| 57 |
+
weight=0.1,
|
| 58 |
+
),
|
| 59 |
+
]
|
| 60 |
+
return Blueprint(
|
| 61 |
+
name="short_ad",
|
| 62 |
+
description="Punchy short ad for Reels/Shorts/TikTok with strong hook and CTA.",
|
| 63 |
+
beats=beats,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _ugc_review() -> Blueprint:
|
| 68 |
+
beats = [
|
| 69 |
+
BeatTemplate(
|
| 70 |
+
id="intro",
|
| 71 |
+
title="UGC Intro",
|
| 72 |
+
goal="Introduce yourself quickly and mention the product.",
|
| 73 |
+
weight=0.2,
|
| 74 |
+
),
|
| 75 |
+
BeatTemplate(
|
| 76 |
+
id="before",
|
| 77 |
+
title="Before",
|
| 78 |
+
goal="Describe life before using the product (the struggle).",
|
| 79 |
+
weight=0.25,
|
| 80 |
+
),
|
| 81 |
+
BeatTemplate(
|
| 82 |
+
id="experience",
|
| 83 |
+
title="Experience",
|
| 84 |
+
goal="Describe what it was like actually trying the product.",
|
| 85 |
+
weight=0.3,
|
| 86 |
+
),
|
| 87 |
+
BeatTemplate(
|
| 88 |
+
id="after",
|
| 89 |
+
title="After",
|
| 90 |
+
goal="Describe the positive results / outcome.",
|
| 91 |
+
weight=0.15,
|
| 92 |
+
),
|
| 93 |
+
BeatTemplate(
|
| 94 |
+
id="recommend",
|
| 95 |
+
title="Recommendation & CTA",
|
| 96 |
+
goal="Recommend the product and give a simple prompt to act.",
|
| 97 |
+
weight=0.1,
|
| 98 |
+
),
|
| 99 |
+
]
|
| 100 |
+
return Blueprint(
|
| 101 |
+
name="ugc_review",
|
| 102 |
+
description="User-generated style review with before/after flow.",
|
| 103 |
+
beats=beats,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _how_to() -> Blueprint:
|
| 108 |
+
beats = [
|
| 109 |
+
BeatTemplate(
|
| 110 |
+
id="intro",
|
| 111 |
+
title="Intro",
|
| 112 |
+
goal="Tell viewers what they will learn and why it matters.",
|
| 113 |
+
weight=0.2,
|
| 114 |
+
),
|
| 115 |
+
BeatTemplate(
|
| 116 |
+
id="step1",
|
| 117 |
+
title="Step 1",
|
| 118 |
+
goal="Explain and demo the first key step.",
|
| 119 |
+
weight=0.25,
|
| 120 |
+
),
|
| 121 |
+
BeatTemplate(
|
| 122 |
+
id="step2",
|
| 123 |
+
title="Step 2",
|
| 124 |
+
goal="Explain and demo the second key step.",
|
| 125 |
+
weight=0.25,
|
| 126 |
+
),
|
| 127 |
+
BeatTemplate(
|
| 128 |
+
id="step3",
|
| 129 |
+
title="Step 3",
|
| 130 |
+
goal="Optional third step or bonus tip.",
|
| 131 |
+
weight=0.15,
|
| 132 |
+
),
|
| 133 |
+
BeatTemplate(
|
| 134 |
+
id="wrap",
|
| 135 |
+
title="Recap & CTA",
|
| 136 |
+
goal="Recap key points and suggest the next action.",
|
| 137 |
+
weight=0.15,
|
| 138 |
+
),
|
| 139 |
+
]
|
| 140 |
+
return Blueprint(
|
| 141 |
+
name="how_to",
|
| 142 |
+
description="Educational explainer with clear steps and recap.",
|
| 143 |
+
beats=beats,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
BLUEPRINTS: Dict[str, Blueprint] = {
|
| 148 |
+
"short_ad": _short_ad(),
|
| 149 |
+
"ugc_review": _ugc_review(),
|
| 150 |
+
"how_to": _how_to(),
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
DEFAULT_BLUEPRINT = "short_ad"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def get_blueprint(name: str) -> Blueprint:
|
| 158 |
+
"""Return a known blueprint or the default one."""
|
| 159 |
+
if name in BLUEPRINTS:
|
| 160 |
+
return BLUEPRINTS[name]
|
| 161 |
+
return BLUEPRINTS[DEFAULT_BLUEPRINT]
|
helpers/json_utils.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
JSON extraction and fallback utilities for video scripting.
|
| 3 |
+
|
| 4 |
+
We try to pull a JSON object out of a model's response, and if
|
| 5 |
+
that fails, we return None so the caller can use a fallback block.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def extract_json_block(text: str) -> Optional[Dict[str, Any]]:
|
| 13 |
+
"""
|
| 14 |
+
Try to parse a JSON object from the given text.
|
| 15 |
+
|
| 16 |
+
Strategy:
|
| 17 |
+
1. Try json.loads on the whole string.
|
| 18 |
+
2. If that fails, look for the first '{' and last '}' and parse that slice.
|
| 19 |
+
3. If still failing, return None.
|
| 20 |
+
"""
|
| 21 |
+
text = text.strip()
|
| 22 |
+
if not text:
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
# 1) raw attempt
|
| 26 |
+
try:
|
| 27 |
+
obj = json.loads(text)
|
| 28 |
+
if isinstance(obj, dict):
|
| 29 |
+
return obj
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
# 2) substring between first '{' and last '}'
|
| 34 |
+
start = text.find("{")
|
| 35 |
+
end = text.rfind("}")
|
| 36 |
+
if start == -1 or end == -1 or end <= start:
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
candidate = text[start : end + 1]
|
| 40 |
+
try:
|
| 41 |
+
obj = json.loads(candidate)
|
| 42 |
+
if isinstance(obj, dict):
|
| 43 |
+
return obj
|
| 44 |
+
except Exception:
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def fallback_block(beat_title: str) -> Dict[str, Any]:
|
| 51 |
+
"""
|
| 52 |
+
Provide a safe default block when JSON parsing fails.
|
| 53 |
+
|
| 54 |
+
The strings here are intentionally generic; the model's real
|
| 55 |
+
responses should normally override these when JSON is valid.
|
| 56 |
+
"""
|
| 57 |
+
return {
|
| 58 |
+
"voiceover": f"Introduce the idea for the '{beat_title}' part in a clear, simple line.",
|
| 59 |
+
"on_screen": f"{beat_title} on screen.",
|
| 60 |
+
"shots": [
|
| 61 |
+
f"Shot of the main subject related to {beat_title.lower()}.",
|
| 62 |
+
"Close-up shot for extra detail.",
|
| 63 |
+
"Wide shot to show context or environment.",
|
| 64 |
+
],
|
| 65 |
+
"broll": [
|
| 66 |
+
"Supporting b-roll that reinforces the message.",
|
| 67 |
+
"Cutaway showing product or user in action.",
|
| 68 |
+
],
|
| 69 |
+
"captions": [
|
| 70 |
+
f"{beat_title} caption text.",
|
| 71 |
+
],
|
| 72 |
+
}
|
helpers/platform_rules.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
# --- Core platform config (caps, hashtags, emojis) ---
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class PlatformConfig:
|
| 10 |
+
"""
|
| 11 |
+
Basic platform constraints used by the validator and pipelines.
|
| 12 |
+
"""
|
| 13 |
+
name: str
|
| 14 |
+
char_cap: int
|
| 15 |
+
hashtags_max: int
|
| 16 |
+
emoji_max: int
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def cap(self) -> int:
|
| 20 |
+
"""
|
| 21 |
+
Backwards-compatible alias for char_cap.
|
| 22 |
+
Some older code might reference .cap instead of .char_cap.
|
| 23 |
+
"""
|
| 24 |
+
return self.char_cap
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Character caps and simple rules per platform.
|
| 28 |
+
PLATFORM_RULES: Dict[str, PlatformConfig] = {
|
| 29 |
+
"Instagram": PlatformConfig(
|
| 30 |
+
name="Instagram",
|
| 31 |
+
char_cap=2200,
|
| 32 |
+
hashtags_max=5,
|
| 33 |
+
emoji_max=5,
|
| 34 |
+
),
|
| 35 |
+
"Facebook": PlatformConfig(
|
| 36 |
+
name="Facebook",
|
| 37 |
+
char_cap=125,
|
| 38 |
+
hashtags_max=0,
|
| 39 |
+
emoji_max=1,
|
| 40 |
+
),
|
| 41 |
+
"LinkedIn": PlatformConfig(
|
| 42 |
+
name="LinkedIn",
|
| 43 |
+
char_cap=3000,
|
| 44 |
+
hashtags_max=3,
|
| 45 |
+
emoji_max=2,
|
| 46 |
+
),
|
| 47 |
+
"Twitter": PlatformConfig(
|
| 48 |
+
name="Twitter",
|
| 49 |
+
char_cap=280,
|
| 50 |
+
hashtags_max=2,
|
| 51 |
+
emoji_max=2,
|
| 52 |
+
),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
DEFAULT_PLATFORM_NAME: str = "Instagram"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- Banned phrase map (for safer language) ---
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Regex patterns mapped to replacement phrases.
|
| 62 |
+
# The validator will use this to make copy less spammy / risky.
|
| 63 |
+
BANNED_MAP: Dict[str, str] = {
|
| 64 |
+
r"\bguarantee(d|s)?\b": "aim to",
|
| 65 |
+
r"\bno[-\s]?risk\b": "low risk",
|
| 66 |
+
# Add more patterns as needed
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# --- Platform style profiles (Phase 3) ---
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# Each entry describes how copy should "feel" on that platform.
|
| 74 |
+
# These are used at prompt level in chat_chain so the LLM
|
| 75 |
+
# clearly understands the expectations per platform.
|
| 76 |
+
PLATFORM_STYLES: Dict[str, Dict] = {
|
| 77 |
+
"Instagram": {
|
| 78 |
+
"name": "Instagram",
|
| 79 |
+
"voice": (
|
| 80 |
+
"fun, casual, and energetic. Speak like a friendly social media manager "
|
| 81 |
+
"talking to followers."
|
| 82 |
+
),
|
| 83 |
+
"emoji_guideline": (
|
| 84 |
+
"Emojis are welcome. Use them to enhance the energy of the post, "
|
| 85 |
+
"but avoid clutter."
|
| 86 |
+
),
|
| 87 |
+
"hashtag_guideline": (
|
| 88 |
+
"Use 3–5 relevant hashtags at the end of the post. "
|
| 89 |
+
"Hashtags should be short, readable, and on-topic."
|
| 90 |
+
),
|
| 91 |
+
"length_guideline": "Short to medium length caption is ideal.",
|
| 92 |
+
},
|
| 93 |
+
"Facebook": {
|
| 94 |
+
"name": "Facebook",
|
| 95 |
+
"voice": (
|
| 96 |
+
"friendly and conversational, but slightly more explanatory than Instagram."
|
| 97 |
+
),
|
| 98 |
+
"emoji_guideline": (
|
| 99 |
+
"Emojis are allowed, but use them sparingly for emphasis only."
|
| 100 |
+
),
|
| 101 |
+
"hashtag_guideline": (
|
| 102 |
+
"One or two hashtags are okay, but they are optional. "
|
| 103 |
+
"Focus more on clear, readable text."
|
| 104 |
+
),
|
| 105 |
+
"length_guideline": "Short to medium length post with a clear main message.",
|
| 106 |
+
},
|
| 107 |
+
"LinkedIn": {
|
| 108 |
+
"name": "LinkedIn",
|
| 109 |
+
"voice": (
|
| 110 |
+
"professional, clear, and value-focused. "
|
| 111 |
+
"Write like a marketer speaking to working professionals."
|
| 112 |
+
),
|
| 113 |
+
"emoji_guideline": (
|
| 114 |
+
"Avoid or minimize emojis. If used at all, keep them professional and sparse."
|
| 115 |
+
),
|
| 116 |
+
"hashtag_guideline": (
|
| 117 |
+
"1–3 relevant, professional hashtags are acceptable at the end. "
|
| 118 |
+
"Do not overuse hashtags."
|
| 119 |
+
),
|
| 120 |
+
"length_guideline": (
|
| 121 |
+
"Short to medium length update. Prioritize clarity and professionalism."
|
| 122 |
+
),
|
| 123 |
+
},
|
| 124 |
+
"Twitter": {
|
| 125 |
+
"name": "Twitter",
|
| 126 |
+
"voice": (
|
| 127 |
+
"short, punchy, and attention-grabbing. "
|
| 128 |
+
"Get to the point quickly."
|
| 129 |
+
),
|
| 130 |
+
"emoji_guideline": (
|
| 131 |
+
"Emojis are fine but keep them minimal and highly relevant."
|
| 132 |
+
),
|
| 133 |
+
"hashtag_guideline": (
|
| 134 |
+
"1–2 strong, relevant hashtags max. Avoid hashtag spam."
|
| 135 |
+
),
|
| 136 |
+
"length_guideline": "Very concise. Every word should earn its place.",
|
| 137 |
+
},
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
DEFAULT_PLATFORM_STYLE: Dict = PLATFORM_STYLES.get("Instagram")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_platform_style(name: str) -> Dict:
|
| 144 |
+
"""
|
| 145 |
+
Return a style profile dict for a given platform name.
|
| 146 |
+
|
| 147 |
+
If the platform is unknown, fall back to DEFAULT_PLATFORM_STYLE.
|
| 148 |
+
"""
|
| 149 |
+
return PLATFORM_STYLES.get(name, DEFAULT_PLATFORM_STYLE)
|
helpers/platform_styles.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Platform personality profiles for Marketeer.
|
| 3 |
+
|
| 4 |
+
These capture HOW each platform prefers to communicate:
|
| 5 |
+
- voice & tone
|
| 6 |
+
- emoji usage
|
| 7 |
+
- hashtag style
|
| 8 |
+
- formatting preferences
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Dict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class PlatformStyle:
|
| 17 |
+
name: str
|
| 18 |
+
voice: str
|
| 19 |
+
emoji_guideline: str
|
| 20 |
+
hashtag_guideline: str
|
| 21 |
+
formatting_guideline: str
|
| 22 |
+
extra_notes: str = ""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Core style definitions
|
| 26 |
+
PLATFORM_STYLES: Dict[str, PlatformStyle] = {
|
| 27 |
+
"Instagram": PlatformStyle(
|
| 28 |
+
name="Instagram",
|
| 29 |
+
voice="Casual, energetic, playful. Focus on vibes, feelings, and moments.",
|
| 30 |
+
emoji_guideline=(
|
| 31 |
+
"Use emojis naturally to enhance mood (1–5 per post). "
|
| 32 |
+
"Avoid overloading every word with emojis."
|
| 33 |
+
),
|
| 34 |
+
hashtag_guideline=(
|
| 35 |
+
"Use 3–5 relevant hashtags at the end of the post. "
|
| 36 |
+
"Mix branded and generic hashtags (e.g., #BrewBlissCafe, #WeekendVibes)."
|
| 37 |
+
),
|
| 38 |
+
formatting_guideline=(
|
| 39 |
+
"Short paragraphs, line breaks for readability, occasional emphasis with ALL CAPS "
|
| 40 |
+
"or **bold style** (if supported)."
|
| 41 |
+
),
|
| 42 |
+
extra_notes="Hook in the first line. Make it thumb-stopping.",
|
| 43 |
+
),
|
| 44 |
+
"Facebook": PlatformStyle(
|
| 45 |
+
name="Facebook",
|
| 46 |
+
voice="Friendly and conversational, but a bit more explanatory than Instagram.",
|
| 47 |
+
emoji_guideline=(
|
| 48 |
+
"Use emojis sparingly (0–2 per post), mainly to highlight key ideas."
|
| 49 |
+
),
|
| 50 |
+
hashtag_guideline=(
|
| 51 |
+
"Hashtags are optional. If used, limit to 1–2 relevant tags."
|
| 52 |
+
),
|
| 53 |
+
formatting_guideline=(
|
| 54 |
+
"1–3 short paragraphs. Clear, readable, and easy to skim."
|
| 55 |
+
),
|
| 56 |
+
extra_notes="Good place for slightly longer explanations or promotions.",
|
| 57 |
+
),
|
| 58 |
+
"LinkedIn": PlatformStyle(
|
| 59 |
+
name="LinkedIn",
|
| 60 |
+
voice=(
|
| 61 |
+
"Professional, clear, and value-driven. Focus on benefits, outcomes, and credibility. "
|
| 62 |
+
"Write as if speaking to working professionals."
|
| 63 |
+
),
|
| 64 |
+
emoji_guideline=(
|
| 65 |
+
"Avoid emojis in most cases. If absolutely necessary, limit to 0–1 subtle emoji."
|
| 66 |
+
),
|
| 67 |
+
hashtag_guideline=(
|
| 68 |
+
"Use 0–3 professional hashtags at the end if needed (e.g., #Marketing, #CustomerExperience)."
|
| 69 |
+
),
|
| 70 |
+
formatting_guideline=(
|
| 71 |
+
"Short, well-structured paragraphs. Avoid slang. No all-caps. "
|
| 72 |
+
"Sound confident and polished."
|
| 73 |
+
),
|
| 74 |
+
extra_notes="Highlight business value, customer experience, and trust.",
|
| 75 |
+
),
|
| 76 |
+
"Twitter": PlatformStyle(
|
| 77 |
+
name="Twitter",
|
| 78 |
+
voice="Short, punchy, and to the point. Witty if possible.",
|
| 79 |
+
emoji_guideline=(
|
| 80 |
+
"Use emojis sparingly (0–2) to add flavor, not clutter."
|
| 81 |
+
),
|
| 82 |
+
hashtag_guideline=(
|
| 83 |
+
"Use 1–3 short hashtags. Prioritize relevance over quantity."
|
| 84 |
+
),
|
| 85 |
+
formatting_guideline=(
|
| 86 |
+
"Single-paragraph or a short thread. Max impact in minimal characters."
|
| 87 |
+
),
|
| 88 |
+
extra_notes="Lead with the core hook in the first few words.",
|
| 89 |
+
),
|
| 90 |
+
# Fallback / generic style
|
| 91 |
+
"Generic": PlatformStyle(
|
| 92 |
+
name="Generic",
|
| 93 |
+
voice="Clear, friendly, and informative.",
|
| 94 |
+
emoji_guideline="Use emojis only if they genuinely add clarity or mood.",
|
| 95 |
+
hashtag_guideline="Use a small number of relevant hashtags if appropriate.",
|
| 96 |
+
formatting_guideline="Keep sentences and paragraphs easy to read.",
|
| 97 |
+
extra_notes="Adapt tone slightly based on the brand and audience.",
|
| 98 |
+
),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
DEFAULT_STYLE_NAME = "Generic"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_platform_style(name: str) -> PlatformStyle:
|
| 106 |
+
"""
|
| 107 |
+
Return the platform style for the given name, falling back to Generic.
|
| 108 |
+
"""
|
| 109 |
+
if name in PLATFORM_STYLES:
|
| 110 |
+
return PLATFORM_STYLES[name]
|
| 111 |
+
return PLATFORM_STYLES[DEFAULT_STYLE_NAME]
|
helpers/validators.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Validation and gentle editing layer for generated copy.
|
| 3 |
+
|
| 4 |
+
This module:
|
| 5 |
+
- Applies banned-term replacements (e.g., "guaranteed" -> "aim to").
|
| 6 |
+
- Trims text to platform character cap.
|
| 7 |
+
- Returns an audit log of what changed.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import List, Dict, Tuple
|
| 11 |
+
|
| 12 |
+
from .platform_rules import PlatformConfig
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Soft language map: you can expand this list as you like
|
| 16 |
+
BANNED_MAP = {
|
| 17 |
+
"guaranteed": "aim to",
|
| 18 |
+
"guarantee": "aim to",
|
| 19 |
+
"no risk": "low risk",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _apply_banned_terms(text: str) -> Tuple[str, List[Dict]]:
|
| 24 |
+
"""Replace banned phrases and record changes."""
|
| 25 |
+
audit: List[Dict] = []
|
| 26 |
+
cleaned = text
|
| 27 |
+
|
| 28 |
+
for bad, replacement in BANNED_MAP.items():
|
| 29 |
+
if bad.lower() in cleaned.lower():
|
| 30 |
+
before = cleaned
|
| 31 |
+
# simple case-insensitive replace
|
| 32 |
+
cleaned = cleaned.replace(bad, replacement)
|
| 33 |
+
cleaned = cleaned.replace(bad.capitalize(), replacement)
|
| 34 |
+
cleaned = cleaned.replace(bad.upper(), replacement.upper())
|
| 35 |
+
audit.append(
|
| 36 |
+
{
|
| 37 |
+
"rule": "banned_term",
|
| 38 |
+
"bad": bad,
|
| 39 |
+
"replacement": replacement,
|
| 40 |
+
}
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return cleaned, audit
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _apply_length_cap(text: str, platform: PlatformConfig) -> Tuple[str, List[Dict]]:
|
| 47 |
+
"""Trim text to the platform's character cap if necessary."""
|
| 48 |
+
audit: List[Dict] = []
|
| 49 |
+
cap = platform.char_cap
|
| 50 |
+
|
| 51 |
+
if len(text) > cap:
|
| 52 |
+
before_len = len(text)
|
| 53 |
+
trimmed = text[:cap].rstrip()
|
| 54 |
+
audit.append(
|
| 55 |
+
{
|
| 56 |
+
"rule": "length_trim",
|
| 57 |
+
"before_len": before_len,
|
| 58 |
+
"after_len": len(trimmed),
|
| 59 |
+
"cap": cap,
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
return trimmed, audit
|
| 63 |
+
|
| 64 |
+
return text, audit
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def validate_and_edit(
|
| 68 |
+
text: str,
|
| 69 |
+
platform: PlatformConfig,
|
| 70 |
+
) -> Tuple[str, List[Dict]]:
|
| 71 |
+
"""
|
| 72 |
+
Apply all validators in order and collect a combined audit log.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
final_text, audit_log
|
| 76 |
+
"""
|
| 77 |
+
audit_log: List[Dict] = []
|
| 78 |
+
|
| 79 |
+
# 1) banned terms
|
| 80 |
+
text, banned_audit = _apply_banned_terms(text)
|
| 81 |
+
audit_log.extend(banned_audit)
|
| 82 |
+
|
| 83 |
+
# 2) length trim
|
| 84 |
+
text, trim_audit = _apply_length_cap(text, platform)
|
| 85 |
+
audit_log.extend(trim_audit)
|
| 86 |
+
|
| 87 |
+
# (you can add more steps later: CTA normalization, emoji limits, etc.)
|
| 88 |
+
return text, audit_log
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas
|
| 2 |
+
torch
|
| 3 |
+
transformers
|
| 4 |
+
rich
|
| 5 |
+
sentence-transformers
|
| 6 |
+
textstat
|
| 7 |
+
regex
|
| 8 |
+
tiktoken
|
| 9 |
+
accelerate
|
| 10 |
+
ipywidgets
|
| 11 |
+
ipykernel
|
| 12 |
+
gradio
|
| 13 |
+
langchain
|
| 14 |
+
langchain-core
|
| 15 |
+
langchain-community
|
| 16 |
+
langchain-huggingface
|
| 17 |
+
bitsandbytes
|
test_llm_backend.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from core_logic.llm_config import get_local_chat_model
|
| 2 |
+
from langchain_core.messages import HumanMessage
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def main():
|
| 6 |
+
# Load the ChatHuggingFace model (cached after first call)
|
| 7 |
+
chat_model = get_local_chat_model()
|
| 8 |
+
|
| 9 |
+
# Instead of SystemMessage, fold instructions into the human message
|
| 10 |
+
prompt = (
|
| 11 |
+
"You are a friendly marketing copywriter.\n\n"
|
| 12 |
+
"Write a short, fun one-line ad for a coffee shop."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
messages = [
|
| 16 |
+
HumanMessage(content=prompt),
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
# Call the model
|
| 20 |
+
ai_msg = chat_model.invoke(messages)
|
| 21 |
+
|
| 22 |
+
# Show the result
|
| 23 |
+
print("Response type:", type(ai_msg))
|
| 24 |
+
print("Response content:\n")
|
| 25 |
+
print(ai_msg.content)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
main()
|
todo.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1. Merge the both screens, when clicked on generate copy , first it gives the result, then user mustt be able to provide the inuputs in the chat window.
|
| 2 |
+
2. Campain goals can be dropdown, generally campaing goals are fixed like lead generation, increase awareness, drive website traffic etc.
|
| 3 |
+
3. Insert feedback section, to receive inuts from the users who used the tool.
|
ui/__init__.py
ADDED
|
File without changes
|
ui/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
ui/__pycache__/gradio_ui.cpython-310.pyc
ADDED
|
Binary file (11.2 kB). View file
|
|
|
ui/gradio_ui.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio UI for Marketeer (copy + video script).
|
| 3 |
+
|
| 4 |
+
New UX:
|
| 5 |
+
|
| 6 |
+
- User fills the campaign form (brand, product, audience, goal, platform, tone, CTA, extra context).
|
| 7 |
+
- Clicking "Generate Copy" creates a FIRST DRAFT and shows it as an assistant
|
| 8 |
+
message in a single chat interface.
|
| 9 |
+
- The user then continues the conversation in that SAME chat window
|
| 10 |
+
(no separate one-shot box / separate draft box).
|
| 11 |
+
- Feedback is linked to the LAST assistant response via a simple
|
| 12 |
+
rating + comment section under the chat.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Any, Dict, List
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
|
| 19 |
+
from core_logic.copy_pipeline import CopyRequest, generate_copy
|
| 20 |
+
from core_logic.video_pipeline import VideoRequest, generate_video_script
|
| 21 |
+
# from core_logic.chat_chain import chat_turn
|
| 22 |
+
from core_logic.chat_agent import agent_chat_turn
|
| 23 |
+
from core_logic.copy_pipeline import CopyRequest
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ----- Small helpers -----
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _build_goal_text(goal_preset: str, goal_custom: str) -> str:
|
| 32 |
+
"""
|
| 33 |
+
Combine preset and custom goal fields into one text.
|
| 34 |
+
|
| 35 |
+
Logic:
|
| 36 |
+
- If custom goal is provided, use that.
|
| 37 |
+
- Else, use the preset goal (dropdown).
|
| 38 |
+
- Else, empty string.
|
| 39 |
+
"""
|
| 40 |
+
goal_custom = (goal_custom or "").strip()
|
| 41 |
+
goal_preset = (goal_preset or "").strip()
|
| 42 |
+
return goal_custom or goal_preset or ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ----- Backend wrapper functions for Gradio -----
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _generate_first_copy_ui(
|
| 49 |
+
brand: str,
|
| 50 |
+
product: str,
|
| 51 |
+
audience: str,
|
| 52 |
+
goal_preset: str,
|
| 53 |
+
goal_custom: str,
|
| 54 |
+
platform_name: str,
|
| 55 |
+
tone: str,
|
| 56 |
+
cta_style: str,
|
| 57 |
+
extra_context: str,
|
| 58 |
+
):
|
| 59 |
+
"""
|
| 60 |
+
First-step copy generation using the form fields.
|
| 61 |
+
The result is shown as the FIRST assistant message in the chat.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
- chat_history: list of [user, assistant] pairs for the Chatbot component
|
| 65 |
+
Here we start with a single assistant message containing the first draft.
|
| 66 |
+
"""
|
| 67 |
+
goal_text = _build_goal_text(goal_preset, goal_custom)
|
| 68 |
+
|
| 69 |
+
req = CopyRequest(
|
| 70 |
+
brand=brand or "",
|
| 71 |
+
product=product or "",
|
| 72 |
+
audience=audience or "",
|
| 73 |
+
goal=goal_text or "",
|
| 74 |
+
platform_name=platform_name or "Instagram",
|
| 75 |
+
tone=tone or "friendly",
|
| 76 |
+
cta_style=cta_style or "soft",
|
| 77 |
+
extra_context=extra_context or "",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
resp = generate_copy(req)
|
| 81 |
+
|
| 82 |
+
first_post = (resp.final or "").strip()
|
| 83 |
+
if not first_post:
|
| 84 |
+
first_post = "I tried to generate a post, but the result was empty. Please try again."
|
| 85 |
+
|
| 86 |
+
# Seed chat: one assistant message with the first draft
|
| 87 |
+
chat_history: List[List[str]] = [["", first_post]]
|
| 88 |
+
|
| 89 |
+
return chat_history
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _chat_copy_ui(
|
| 93 |
+
chat_history,
|
| 94 |
+
user_message: str,
|
| 95 |
+
brand: str,
|
| 96 |
+
product: str,
|
| 97 |
+
audience: str,
|
| 98 |
+
goal_preset: str,
|
| 99 |
+
goal_custom: str,
|
| 100 |
+
platform_name: str,
|
| 101 |
+
tone: str,
|
| 102 |
+
cta_style: str,
|
| 103 |
+
extra_context: str,
|
| 104 |
+
):
|
| 105 |
+
"""
|
| 106 |
+
Chat handler for the Copy tab using the advanced agent with tools.
|
| 107 |
+
|
| 108 |
+
Parameters must match the order of inputs in send_btn.click():
|
| 109 |
+
|
| 110 |
+
inputs=[
|
| 111 |
+
chatbox,
|
| 112 |
+
user_msg,
|
| 113 |
+
brand,
|
| 114 |
+
product,
|
| 115 |
+
audience,
|
| 116 |
+
goal_preset,
|
| 117 |
+
goal_custom,
|
| 118 |
+
platform_name,
|
| 119 |
+
tone,
|
| 120 |
+
cta_style,
|
| 121 |
+
extra_context,
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
- Uses campaign context (brand, product, audience, goal, platform, tone, CTA)
|
| 125 |
+
- Uses chat_history (list of [user, assistant] pairs) as previous conversation
|
| 126 |
+
- Returns updated chat_history and clears the input box.
|
| 127 |
+
"""
|
| 128 |
+
# If user_message is empty, just return the same state
|
| 129 |
+
if not user_message or not user_message.strip():
|
| 130 |
+
return chat_history, user_message
|
| 131 |
+
|
| 132 |
+
# Merge preset + custom goal into a single text
|
| 133 |
+
goal_text = _build_goal_text(goal_preset, goal_custom)
|
| 134 |
+
|
| 135 |
+
# Build the CopyRequest from the form fields
|
| 136 |
+
req = CopyRequest(
|
| 137 |
+
brand=brand or "",
|
| 138 |
+
product=product or "",
|
| 139 |
+
audience=audience or "",
|
| 140 |
+
goal=goal_text,
|
| 141 |
+
platform_name=platform_name or "Instagram",
|
| 142 |
+
tone=tone or "friendly",
|
| 143 |
+
cta_style=cta_style or "soft",
|
| 144 |
+
extra_context=extra_context or "",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Gradio Chatbot history comes in as list of [user, assistant] pairs
|
| 148 |
+
history_pairs = chat_history or []
|
| 149 |
+
|
| 150 |
+
# Call our advanced agent (which can use rewrite tools internally)
|
| 151 |
+
final_text, raw_text, audit = agent_chat_turn(
|
| 152 |
+
req=req,
|
| 153 |
+
user_message=user_message,
|
| 154 |
+
history_pairs=history_pairs,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Append the new turn to history
|
| 158 |
+
new_history = history_pairs + [[user_message, final_text]]
|
| 159 |
+
|
| 160 |
+
# Return updated history and clear the input box
|
| 161 |
+
return new_history, ""
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _clear_chat():
|
| 165 |
+
"""
|
| 166 |
+
Clear chat history.
|
| 167 |
+
"""
|
| 168 |
+
return []
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _submit_feedback_for_last_reply(
|
| 172 |
+
chat_history,
|
| 173 |
+
fb_rating: str,
|
| 174 |
+
fb_text: str,
|
| 175 |
+
brand: str,
|
| 176 |
+
platform_name: str,
|
| 177 |
+
goal_preset: str,
|
| 178 |
+
goal_custom: str,
|
| 179 |
+
):
|
| 180 |
+
"""
|
| 181 |
+
Feedback handler tied to the LAST assistant message in the chat.
|
| 182 |
+
|
| 183 |
+
We log:
|
| 184 |
+
- Brand, Platform, Goal
|
| 185 |
+
- Rating (e.g., 👍 / 👎)
|
| 186 |
+
- Free-text feedback
|
| 187 |
+
- The last assistant reply text
|
| 188 |
+
|
| 189 |
+
and return a short status message.
|
| 190 |
+
"""
|
| 191 |
+
if not chat_history:
|
| 192 |
+
return "No messages yet. Generate a post or chat first, then leave feedback."
|
| 193 |
+
|
| 194 |
+
# chat_history is a list of [user, assistant] pairs.
|
| 195 |
+
# The last pair's assistant message is the one we care about.
|
| 196 |
+
last_user, last_assistant = chat_history[-1]
|
| 197 |
+
last_assistant = last_assistant or "(empty reply)"
|
| 198 |
+
|
| 199 |
+
fb_rating = fb_rating or "(not provided)"
|
| 200 |
+
fb_text = fb_text or "(no comment)"
|
| 201 |
+
brand = brand or "(not provided)"
|
| 202 |
+
platform_name = platform_name or "(not provided)"
|
| 203 |
+
|
| 204 |
+
goal_text = _build_goal_text(goal_preset, goal_custom)
|
| 205 |
+
goal_text = goal_text or "(not provided)"
|
| 206 |
+
|
| 207 |
+
print("=== MARKETEER FEEDBACK (last reply) ===")
|
| 208 |
+
print(f"Brand: {brand}")
|
| 209 |
+
print(f"Platform: {platform_name}")
|
| 210 |
+
print(f"Goal: {goal_text}")
|
| 211 |
+
print(f"Rating: {fb_rating}")
|
| 212 |
+
print("User feedback text:")
|
| 213 |
+
print(fb_text)
|
| 214 |
+
print("--- Last assistant reply ---")
|
| 215 |
+
print(last_assistant)
|
| 216 |
+
print("=======================================")
|
| 217 |
+
|
| 218 |
+
return "✅ Thanks for your feedback on the last reply!"
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _generate_video_ui(
|
| 222 |
+
brand: str,
|
| 223 |
+
product: str,
|
| 224 |
+
audience: str,
|
| 225 |
+
goal: str,
|
| 226 |
+
blueprint_name: str,
|
| 227 |
+
duration_sec: int,
|
| 228 |
+
platform_name: str,
|
| 229 |
+
style: str,
|
| 230 |
+
extra_context: str,
|
| 231 |
+
debug_first: bool,
|
| 232 |
+
) -> Dict[str, Any]:
|
| 233 |
+
"""
|
| 234 |
+
Wrapper around generate_video_script() for Gradio.
|
| 235 |
+
Returns storyboard text, JSON, and warnings.
|
| 236 |
+
"""
|
| 237 |
+
req = VideoRequest(
|
| 238 |
+
brand=brand or "",
|
| 239 |
+
product=product or "",
|
| 240 |
+
audience=audience or "",
|
| 241 |
+
goal=goal or "",
|
| 242 |
+
blueprint_name=blueprint_name or "short_ad",
|
| 243 |
+
duration_sec=int(duration_sec) if duration_sec else 20,
|
| 244 |
+
platform_name=platform_name or "Instagram Reels",
|
| 245 |
+
style=style or "warm",
|
| 246 |
+
extra_context=extra_context or "",
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Now returns a VideoScriptResponse (plan + warnings)
|
| 250 |
+
resp = generate_video_script(req, debug_first=bool(debug_first))
|
| 251 |
+
|
| 252 |
+
# --- Build human-readable storyboard from structured beats ---
|
| 253 |
+
sb_lines: List[str] = []
|
| 254 |
+
for beat in resp.beats: # resp.beats is a list[VideoBeat]
|
| 255 |
+
sb_lines.append(
|
| 256 |
+
f"Beat {beat.beat_index + 1}: {beat.title} "
|
| 257 |
+
f"({beat.t_start}s – {beat.t_end}s)"
|
| 258 |
+
)
|
| 259 |
+
sb_lines.append(f" Voiceover: {beat.voiceover}")
|
| 260 |
+
sb_lines.append(f" On-screen: {beat.on_screen}")
|
| 261 |
+
|
| 262 |
+
sb_lines.append(" Shots:")
|
| 263 |
+
for shot in beat.shots:
|
| 264 |
+
sb_lines.append(f" • {shot}")
|
| 265 |
+
|
| 266 |
+
sb_lines.append(" B-roll:")
|
| 267 |
+
for br in beat.broll:
|
| 268 |
+
sb_lines.append(f" • {br}")
|
| 269 |
+
|
| 270 |
+
sb_lines.append(" Captions:")
|
| 271 |
+
for cap in beat.captions:
|
| 272 |
+
sb_lines.append(f" • {cap}")
|
| 273 |
+
|
| 274 |
+
sb_lines.append("") # blank line between beats
|
| 275 |
+
|
| 276 |
+
storyboard_text = "\n".join(sb_lines).strip() or "No beats generated."
|
| 277 |
+
|
| 278 |
+
# --- Warnings text ---
|
| 279 |
+
if resp.warnings:
|
| 280 |
+
warnings_text = "\n".join(f"- {w}" for w in resp.warnings)
|
| 281 |
+
else:
|
| 282 |
+
warnings_text = "No warnings. All beats parsed without fallback. ✅"
|
| 283 |
+
|
| 284 |
+
# --- JSON-ready object for download/integration ---
|
| 285 |
+
script_json: Dict[str, Any] = {
|
| 286 |
+
"plan": {
|
| 287 |
+
"blueprint_name": resp.plan.blueprint_name,
|
| 288 |
+
"duration_sec": resp.plan.duration_sec,
|
| 289 |
+
"platform_name": resp.plan.platform_name,
|
| 290 |
+
"style": resp.plan.style,
|
| 291 |
+
"beats": [
|
| 292 |
+
{
|
| 293 |
+
"index": b.beat_index,
|
| 294 |
+
"title": b.title,
|
| 295 |
+
"goal": b.goal,
|
| 296 |
+
"t_start": b.t_start,
|
| 297 |
+
"t_end": b.t_end,
|
| 298 |
+
}
|
| 299 |
+
for b in resp.plan.beats
|
| 300 |
+
],
|
| 301 |
+
},
|
| 302 |
+
# Full beats payload with all fields (voiceover, shots, etc.)
|
| 303 |
+
"beats": [b.model_dump() for b in resp.beats],
|
| 304 |
+
"warnings": resp.warnings,
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
return storyboard_text, script_json, warnings_text
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ----- Gradio layout -----
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def create_interface() -> gr.Blocks:
|
| 315 |
+
"""
|
| 316 |
+
Create and return the Gradio Blocks interface.
|
| 317 |
+
"""
|
| 318 |
+
with gr.Blocks(title="Marketeer – Copy & Video Script Generator") as demo:
|
| 319 |
+
gr.Markdown(
|
| 320 |
+
"""
|
| 321 |
+
# Marketeer – Copy & Video Script Generator
|
| 322 |
+
|
| 323 |
+
Fill in your campaign details, generate a first draft, then refine it
|
| 324 |
+
in a single chat with your AI copywriter. Also generate short-form video
|
| 325 |
+
storyboards for your campaigns.
|
| 326 |
+
"""
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
with gr.Tabs():
|
| 330 |
+
# --- Tab 1: Copy Chat (single chat interface) ---
|
| 331 |
+
with gr.Tab("Copy Chat"):
|
| 332 |
+
with gr.Row():
|
| 333 |
+
# LEFT COLUMN: Campaign setup
|
| 334 |
+
with gr.Column(scale=1):
|
| 335 |
+
gr.Markdown("### Campaign Setup")
|
| 336 |
+
|
| 337 |
+
brand = gr.Textbox(
|
| 338 |
+
label="Brand / Company",
|
| 339 |
+
placeholder="Brew Bliss Café",
|
| 340 |
+
)
|
| 341 |
+
product = gr.Textbox(
|
| 342 |
+
label="Product / Offer",
|
| 343 |
+
placeholder="signature cold brew",
|
| 344 |
+
)
|
| 345 |
+
audience = gr.Textbox(
|
| 346 |
+
label="Target audience",
|
| 347 |
+
placeholder=(
|
| 348 |
+
"young professionals who love coffee but hate waiting in line"
|
| 349 |
+
),
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Campaign goal: preset dropdown + optional custom
|
| 353 |
+
goal_preset = gr.Dropdown(
|
| 354 |
+
label="Campaign goal",
|
| 355 |
+
choices=[
|
| 356 |
+
"Increase brand awareness",
|
| 357 |
+
"Lead generation",
|
| 358 |
+
"Drive website traffic",
|
| 359 |
+
"Promote in-store visits",
|
| 360 |
+
"Boost engagement",
|
| 361 |
+
"Announce a new product",
|
| 362 |
+
],
|
| 363 |
+
value="Increase brand awareness",
|
| 364 |
+
)
|
| 365 |
+
goal_custom = gr.Textbox(
|
| 366 |
+
label="Custom goal (optional)",
|
| 367 |
+
placeholder="e.g. drive in-store visits this weekend",
|
| 368 |
+
lines=2,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
platform_name = gr.Dropdown(
|
| 372 |
+
label="Platform",
|
| 373 |
+
choices=["Instagram", "Facebook", "LinkedIn", "Twitter"],
|
| 374 |
+
value="Instagram",
|
| 375 |
+
)
|
| 376 |
+
tone = gr.Dropdown(
|
| 377 |
+
label="Tone",
|
| 378 |
+
choices=[
|
| 379 |
+
"friendly",
|
| 380 |
+
"professional",
|
| 381 |
+
"energetic",
|
| 382 |
+
"storytelling",
|
| 383 |
+
],
|
| 384 |
+
value="friendly",
|
| 385 |
+
)
|
| 386 |
+
cta_style = gr.Dropdown(
|
| 387 |
+
label="CTA style",
|
| 388 |
+
choices=["soft", "medium", "hard"],
|
| 389 |
+
value="soft",
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
extra_context = gr.Textbox(
|
| 393 |
+
label="Extra context (optional)",
|
| 394 |
+
placeholder="Mention that we have comfy seating and free Wi-Fi.",
|
| 395 |
+
lines=3,
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
generate_copy_btn = gr.Button(
|
| 399 |
+
"✨ Generate First Draft (and start chat)"
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# RIGHT COLUMN: Chat + Feedback
|
| 403 |
+
with gr.Column(scale=2):
|
| 404 |
+
gr.Markdown("### Chat with your copywriter")
|
| 405 |
+
|
| 406 |
+
chatbox = gr.Chatbot(
|
| 407 |
+
label="Copy Chat (context-aware)",
|
| 408 |
+
height=320,
|
| 409 |
+
)
|
| 410 |
+
user_msg = gr.Textbox(
|
| 411 |
+
label="Your message",
|
| 412 |
+
placeholder=(
|
| 413 |
+
"Examples:\n"
|
| 414 |
+
"- 'Write a first post for this campaign.'\n"
|
| 415 |
+
"- 'Shorten this and keep the main message.'\n"
|
| 416 |
+
"- 'Adapt this for LinkedIn, more professional.'"
|
| 417 |
+
),
|
| 418 |
+
lines=3,
|
| 419 |
+
)
|
| 420 |
+
with gr.Row():
|
| 421 |
+
send_btn = gr.Button("Send")
|
| 422 |
+
clear_btn = gr.Button("Clear Chat")
|
| 423 |
+
|
| 424 |
+
gr.Markdown("#### Feedback on the last reply")
|
| 425 |
+
fb_rating = gr.Radio(
|
| 426 |
+
label="How was the last AI reply?",
|
| 427 |
+
choices=["👍 Helpful", "👌 Okay", "👎 Needs improvement"],
|
| 428 |
+
value="👍 Helpful",
|
| 429 |
+
)
|
| 430 |
+
fb_text = gr.Textbox(
|
| 431 |
+
label="Feedback (optional)",
|
| 432 |
+
placeholder="What worked well? What should be improved?",
|
| 433 |
+
lines=3,
|
| 434 |
+
)
|
| 435 |
+
fb_submit = gr.Button("Submit feedback for last reply")
|
| 436 |
+
fb_status = gr.Markdown("")
|
| 437 |
+
|
| 438 |
+
# Wire first-draft generator (seeds chat only)
|
| 439 |
+
generate_copy_btn.click(
|
| 440 |
+
fn=_generate_first_copy_ui,
|
| 441 |
+
inputs=[
|
| 442 |
+
brand,
|
| 443 |
+
product,
|
| 444 |
+
audience,
|
| 445 |
+
goal_preset,
|
| 446 |
+
goal_custom,
|
| 447 |
+
platform_name,
|
| 448 |
+
tone,
|
| 449 |
+
cta_style,
|
| 450 |
+
extra_context,
|
| 451 |
+
],
|
| 452 |
+
outputs=[chatbox],
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
# Wire chat send button
|
| 456 |
+
send_btn.click(
|
| 457 |
+
fn=_chat_copy_ui,
|
| 458 |
+
inputs=[
|
| 459 |
+
chatbox,
|
| 460 |
+
user_msg,
|
| 461 |
+
brand,
|
| 462 |
+
product,
|
| 463 |
+
audience,
|
| 464 |
+
goal_preset,
|
| 465 |
+
goal_custom,
|
| 466 |
+
platform_name,
|
| 467 |
+
tone,
|
| 468 |
+
cta_style,
|
| 469 |
+
extra_context,
|
| 470 |
+
],
|
| 471 |
+
outputs=[chatbox, user_msg],
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
# Wire chat clear button
|
| 475 |
+
clear_btn.click(
|
| 476 |
+
fn=_clear_chat,
|
| 477 |
+
inputs=None,
|
| 478 |
+
outputs=[chatbox],
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
# Wire feedback button (linked to last assistant reply)
|
| 482 |
+
fb_submit.click(
|
| 483 |
+
fn=_submit_feedback_for_last_reply,
|
| 484 |
+
inputs=[
|
| 485 |
+
chatbox,
|
| 486 |
+
fb_rating,
|
| 487 |
+
fb_text,
|
| 488 |
+
brand,
|
| 489 |
+
platform_name,
|
| 490 |
+
goal_preset,
|
| 491 |
+
goal_custom,
|
| 492 |
+
],
|
| 493 |
+
outputs=[fb_status],
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
# --- Tab 2: Video Script Generator (unchanged logic) ---
|
| 497 |
+
with gr.Tab("Video Script Generator"):
|
| 498 |
+
with gr.Row():
|
| 499 |
+
with gr.Column():
|
| 500 |
+
v_brand = gr.Textbox(
|
| 501 |
+
label="Brand / Company",
|
| 502 |
+
placeholder="Brew Bliss Café",
|
| 503 |
+
)
|
| 504 |
+
v_product = gr.Textbox(
|
| 505 |
+
label="Product",
|
| 506 |
+
placeholder="signature cold brew",
|
| 507 |
+
)
|
| 508 |
+
v_audience = gr.Textbox(
|
| 509 |
+
label="Target audience",
|
| 510 |
+
placeholder=(
|
| 511 |
+
"young professionals who love coffee but hate waiting in line"
|
| 512 |
+
),
|
| 513 |
+
)
|
| 514 |
+
v_goal = gr.Textbox(
|
| 515 |
+
label="Campaign goal",
|
| 516 |
+
placeholder="drive in-store visits this weekend",
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
blueprint_name = gr.Dropdown(
|
| 520 |
+
label="Blueprint",
|
| 521 |
+
choices=["short_ad", "ugc_review", "how_to"],
|
| 522 |
+
value="short_ad",
|
| 523 |
+
)
|
| 524 |
+
duration_sec = gr.Slider(
|
| 525 |
+
label="Video duration (seconds)",
|
| 526 |
+
minimum=5,
|
| 527 |
+
maximum=60,
|
| 528 |
+
step=1,
|
| 529 |
+
value=20,
|
| 530 |
+
)
|
| 531 |
+
platform_name_v = gr.Textbox(
|
| 532 |
+
label="Platform label (for prompt)",
|
| 533 |
+
value="Instagram Reels",
|
| 534 |
+
)
|
| 535 |
+
style = gr.Textbox(
|
| 536 |
+
label="Style",
|
| 537 |
+
value="warm and energetic",
|
| 538 |
+
)
|
| 539 |
+
extra_context_v = gr.Textbox(
|
| 540 |
+
label="Extra context (optional)",
|
| 541 |
+
placeholder=(
|
| 542 |
+
"Focus on escaping the grind and enjoying a chilled moment."
|
| 543 |
+
),
|
| 544 |
+
lines=3,
|
| 545 |
+
)
|
| 546 |
+
debug_first = gr.Checkbox(
|
| 547 |
+
label="Print raw first beat to server logs (debug)",
|
| 548 |
+
value=False,
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
generate_video_btn = gr.Button("Generate Video Script")
|
| 552 |
+
|
| 553 |
+
with gr.Column():
|
| 554 |
+
storyboard = gr.Textbox(
|
| 555 |
+
label="Storyboard (per beat)",
|
| 556 |
+
lines=18,
|
| 557 |
+
)
|
| 558 |
+
warnings_box = gr.Textbox(
|
| 559 |
+
label="Warnings",
|
| 560 |
+
lines=6,
|
| 561 |
+
)
|
| 562 |
+
script_json = gr.JSON(
|
| 563 |
+
label="Full script JSON (for download/integration)",
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
generate_video_btn.click(
|
| 567 |
+
fn=_generate_video_ui,
|
| 568 |
+
inputs=[
|
| 569 |
+
v_brand,
|
| 570 |
+
v_product,
|
| 571 |
+
v_audience,
|
| 572 |
+
v_goal,
|
| 573 |
+
blueprint_name,
|
| 574 |
+
duration_sec,
|
| 575 |
+
platform_name_v,
|
| 576 |
+
style,
|
| 577 |
+
extra_context_v,
|
| 578 |
+
debug_first,
|
| 579 |
+
],
|
| 580 |
+
outputs=[storyboard, script_json, warnings_box],
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
return demo
|
ui/gradio_ui_1.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio UI for Marketeer (copy + video script).
|
| 3 |
+
|
| 4 |
+
This file wires the core logic into a simple web interface
|
| 5 |
+
with two tabs:
|
| 6 |
+
- Copy Generator
|
| 7 |
+
- Video Script Generator
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
from core_logic.copy_pipeline import CopyRequest, generate_copy
|
| 15 |
+
from core_logic.video_pipeline import VideoRequest, generate_video_script
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ----- Backend wrapper functions for Gradio -----
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _generate_copy_ui(
|
| 22 |
+
brand: str,
|
| 23 |
+
product: str,
|
| 24 |
+
audience: str,
|
| 25 |
+
goal: str,
|
| 26 |
+
platform_name: str,
|
| 27 |
+
tone: str,
|
| 28 |
+
cta_style: str,
|
| 29 |
+
extra_context: str,
|
| 30 |
+
):
|
| 31 |
+
"""
|
| 32 |
+
Wrapper around generate_copy() for Gradio.
|
| 33 |
+
Returns final_text, raw_text, audit_text in that order.
|
| 34 |
+
"""
|
| 35 |
+
req = CopyRequest(
|
| 36 |
+
brand=brand or "",
|
| 37 |
+
product=product or "",
|
| 38 |
+
audience=audience or "",
|
| 39 |
+
goal=goal or "",
|
| 40 |
+
platform_name=platform_name or "Instagram",
|
| 41 |
+
tone=tone or "friendly",
|
| 42 |
+
cta_style=cta_style or "soft",
|
| 43 |
+
extra_context=extra_context or "",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
resp = generate_copy(req)
|
| 47 |
+
|
| 48 |
+
# Pretty-print audit log
|
| 49 |
+
if resp.audit:
|
| 50 |
+
audit_lines = []
|
| 51 |
+
for item in resp.audit:
|
| 52 |
+
rule = item.get("rule", "unknown")
|
| 53 |
+
audit_lines.append(f"- {rule}: {item}")
|
| 54 |
+
audit_text = "\n".join(audit_lines)
|
| 55 |
+
else:
|
| 56 |
+
audit_text = "No edits were needed. ✅"
|
| 57 |
+
|
| 58 |
+
# RETURN IN ORDER: final_copy, raw_output, audit_log
|
| 59 |
+
return resp.final, resp.raw, audit_text
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _generate_video_ui(
|
| 63 |
+
brand: str,
|
| 64 |
+
product: str,
|
| 65 |
+
audience: str,
|
| 66 |
+
goal: str,
|
| 67 |
+
blueprint_name: str,
|
| 68 |
+
duration_sec: int,
|
| 69 |
+
platform_name: str,
|
| 70 |
+
style: str,
|
| 71 |
+
extra_context: str,
|
| 72 |
+
debug_first: bool,
|
| 73 |
+
):
|
| 74 |
+
"""
|
| 75 |
+
Wrapper around generate_video_script() for Gradio.
|
| 76 |
+
Returns storyboard_text, script_json, warnings_text in that order.
|
| 77 |
+
"""
|
| 78 |
+
req = VideoRequest(
|
| 79 |
+
brand=brand or "",
|
| 80 |
+
product=product or "",
|
| 81 |
+
audience=audience or "",
|
| 82 |
+
goal=goal or "",
|
| 83 |
+
blueprint_name=blueprint_name or "short_ad",
|
| 84 |
+
duration_sec=int(duration_sec) if duration_sec else 20,
|
| 85 |
+
platform_name=platform_name or "Instagram Reels",
|
| 86 |
+
style=style or "warm",
|
| 87 |
+
extra_context=extra_context or "",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
resp = generate_video_script(req, debug_first=bool(debug_first))
|
| 91 |
+
|
| 92 |
+
# storyboard text (same as before)
|
| 93 |
+
sb_lines = []
|
| 94 |
+
for block in resp.beats:
|
| 95 |
+
sb_lines.append(
|
| 96 |
+
f"Beat {block['beat_index'] + 1}: {block['beat_title']} "
|
| 97 |
+
f"({block['t_start']}s – {block['t_end']}s)"
|
| 98 |
+
)
|
| 99 |
+
sb_lines.append(f" Voiceover: {block['voiceover']}")
|
| 100 |
+
sb_lines.append(f" On-screen: {block['on_screen']}")
|
| 101 |
+
sb_lines.append(" Shots:")
|
| 102 |
+
for shot in block["shots"]:
|
| 103 |
+
sb_lines.append(f" • {shot}")
|
| 104 |
+
sb_lines.append(" B-roll:")
|
| 105 |
+
for br in block["broll"]:
|
| 106 |
+
sb_lines.append(f" • {br}")
|
| 107 |
+
sb_lines.append(" Captions:")
|
| 108 |
+
for cap in block["captions"]:
|
| 109 |
+
sb_lines.append(f" • {cap}")
|
| 110 |
+
sb_lines.append("")
|
| 111 |
+
|
| 112 |
+
storyboard_text = "\n".join(sb_lines).strip() or "No beats generated."
|
| 113 |
+
|
| 114 |
+
# warnings text
|
| 115 |
+
if resp.warnings:
|
| 116 |
+
warnings_text = "\n".join(f"- {w}" for w in resp.warnings)
|
| 117 |
+
else:
|
| 118 |
+
warnings_text = "No warnings. All beats parsed without fallback. ✅"
|
| 119 |
+
|
| 120 |
+
# JSON object
|
| 121 |
+
script_json = {
|
| 122 |
+
"plan": {
|
| 123 |
+
"blueprint_name": resp.plan.blueprint_name,
|
| 124 |
+
"duration_sec": resp.plan.duration_sec,
|
| 125 |
+
"platform_name": resp.plan.platform_name,
|
| 126 |
+
"style": resp.plan.style,
|
| 127 |
+
"beats": [
|
| 128 |
+
{
|
| 129 |
+
"index": b.index,
|
| 130 |
+
"title": b.title,
|
| 131 |
+
"goal": b.goal,
|
| 132 |
+
"t_start": b.t_start,
|
| 133 |
+
"t_end": b.t_end,
|
| 134 |
+
}
|
| 135 |
+
for b in resp.plan.beats
|
| 136 |
+
],
|
| 137 |
+
},
|
| 138 |
+
"beats": resp.beats,
|
| 139 |
+
"warnings": resp.warnings,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# RETURN IN ORDER: storyboard, json, warnings
|
| 143 |
+
return storyboard_text, script_json, warnings_text
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ----- Gradio layout -----
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def create_interface() -> gr.Blocks:
|
| 150 |
+
"""
|
| 151 |
+
Create and return the Gradio Blocks interface.
|
| 152 |
+
"""
|
| 153 |
+
with gr.Blocks(title="Marketeer – Copy & Video Script Generator") as demo:
|
| 154 |
+
gr.Markdown(
|
| 155 |
+
"""
|
| 156 |
+
# Marketeer – Copy & Video Script Generator
|
| 157 |
+
|
| 158 |
+
Generate platform-aware marketing copy and short-form video scripts,
|
| 159 |
+
powered by your patched Gemma-based backend.
|
| 160 |
+
"""
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
with gr.Tabs():
|
| 164 |
+
# --- Tab 1: Copy Generator ---
|
| 165 |
+
with gr.Tab("Copy Generator"):
|
| 166 |
+
with gr.Row():
|
| 167 |
+
with gr.Column():
|
| 168 |
+
brand = gr.Textbox(
|
| 169 |
+
label="Brand / Company",
|
| 170 |
+
placeholder="Brew Bliss Café",
|
| 171 |
+
)
|
| 172 |
+
product = gr.Textbox(
|
| 173 |
+
label="Product / Offer",
|
| 174 |
+
placeholder="signature cold brew",
|
| 175 |
+
)
|
| 176 |
+
audience = gr.Textbox(
|
| 177 |
+
label="Target audience",
|
| 178 |
+
placeholder="young professionals who love coffee but hate waiting in line",
|
| 179 |
+
)
|
| 180 |
+
goal = gr.Textbox(
|
| 181 |
+
label="Campaign goal",
|
| 182 |
+
placeholder="drive in-store visits this weekend",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
platform_name = gr.Dropdown(
|
| 186 |
+
label="Platform",
|
| 187 |
+
choices=["Instagram", "Facebook", "LinkedIn", "Twitter"],
|
| 188 |
+
value="Instagram",
|
| 189 |
+
)
|
| 190 |
+
tone = gr.Dropdown(
|
| 191 |
+
label="Tone",
|
| 192 |
+
choices=["friendly", "professional", "energetic", "storytelling"],
|
| 193 |
+
value="friendly",
|
| 194 |
+
)
|
| 195 |
+
cta_style = gr.Dropdown(
|
| 196 |
+
label="CTA style",
|
| 197 |
+
choices=["soft", "medium", "hard"],
|
| 198 |
+
value="soft",
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
extra_context = gr.Textbox(
|
| 202 |
+
label="Extra context (optional)",
|
| 203 |
+
placeholder="Mention that we have comfy seating and free Wi-Fi.",
|
| 204 |
+
lines=3,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
generate_copy_btn = gr.Button("Generate Copy")
|
| 208 |
+
|
| 209 |
+
with gr.Column():
|
| 210 |
+
final_copy = gr.Textbox(
|
| 211 |
+
label="Final Copy",
|
| 212 |
+
lines=10,
|
| 213 |
+
)
|
| 214 |
+
audit_log = gr.Textbox(
|
| 215 |
+
label="Audit Log",
|
| 216 |
+
lines=8,
|
| 217 |
+
)
|
| 218 |
+
raw_output = gr.Textbox(
|
| 219 |
+
label="Raw Model Output (debug)",
|
| 220 |
+
lines=8,
|
| 221 |
+
visible=False, # flip to True if you want to see raw text
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Wire copy button
|
| 225 |
+
generate_copy_btn.click(
|
| 226 |
+
fn=_generate_copy_ui,
|
| 227 |
+
inputs=[
|
| 228 |
+
brand,
|
| 229 |
+
product,
|
| 230 |
+
audience,
|
| 231 |
+
goal,
|
| 232 |
+
platform_name,
|
| 233 |
+
tone,
|
| 234 |
+
cta_style,
|
| 235 |
+
extra_context,
|
| 236 |
+
],
|
| 237 |
+
outputs=[final_copy, raw_output, audit_log],
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# --- Tab 2: Video Script Generator ---
|
| 241 |
+
with gr.Tab("Video Script Generator"):
|
| 242 |
+
with gr.Row():
|
| 243 |
+
with gr.Column():
|
| 244 |
+
v_brand = gr.Textbox(
|
| 245 |
+
label="Brand / Company",
|
| 246 |
+
placeholder="Brew Bliss Café",
|
| 247 |
+
)
|
| 248 |
+
v_product = gr.Textbox(
|
| 249 |
+
label="Product",
|
| 250 |
+
placeholder="signature cold brew",
|
| 251 |
+
)
|
| 252 |
+
v_audience = gr.Textbox(
|
| 253 |
+
label="Target audience",
|
| 254 |
+
placeholder="young professionals who love coffee but hate waiting in line",
|
| 255 |
+
)
|
| 256 |
+
v_goal = gr.Textbox(
|
| 257 |
+
label="Campaign goal",
|
| 258 |
+
placeholder="drive in-store visits this weekend",
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
blueprint_name = gr.Dropdown(
|
| 262 |
+
label="Blueprint",
|
| 263 |
+
choices=["short_ad", "ugc_review", "how_to"],
|
| 264 |
+
value="short_ad",
|
| 265 |
+
)
|
| 266 |
+
duration_sec = gr.Slider(
|
| 267 |
+
label="Video duration (seconds)",
|
| 268 |
+
minimum=5,
|
| 269 |
+
maximum=60,
|
| 270 |
+
step=1,
|
| 271 |
+
value=20,
|
| 272 |
+
)
|
| 273 |
+
platform_name_v = gr.Textbox(
|
| 274 |
+
label="Platform label (for prompt)",
|
| 275 |
+
value="Instagram Reels",
|
| 276 |
+
)
|
| 277 |
+
style = gr.Textbox(
|
| 278 |
+
label="Style",
|
| 279 |
+
value="warm and energetic",
|
| 280 |
+
)
|
| 281 |
+
extra_context_v = gr.Textbox(
|
| 282 |
+
label="Extra context (optional)",
|
| 283 |
+
placeholder="Focus on escaping the grind and enjoying a chilled moment.",
|
| 284 |
+
lines=3,
|
| 285 |
+
)
|
| 286 |
+
debug_first = gr.Checkbox(
|
| 287 |
+
label="Print raw first beat to server logs (debug)",
|
| 288 |
+
value=False,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
generate_video_btn = gr.Button("Generate Video Script")
|
| 292 |
+
|
| 293 |
+
with gr.Column():
|
| 294 |
+
storyboard = gr.Textbox(
|
| 295 |
+
label="Storyboard (per beat)",
|
| 296 |
+
lines=18,
|
| 297 |
+
)
|
| 298 |
+
warnings_box = gr.Textbox(
|
| 299 |
+
label="Warnings",
|
| 300 |
+
lines=6,
|
| 301 |
+
)
|
| 302 |
+
script_json = gr.JSON(
|
| 303 |
+
label="Full script JSON (for download/integration)",
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
generate_video_btn.click(
|
| 307 |
+
fn=_generate_video_ui,
|
| 308 |
+
inputs=[
|
| 309 |
+
v_brand,
|
| 310 |
+
v_product,
|
| 311 |
+
v_audience,
|
| 312 |
+
v_goal,
|
| 313 |
+
blueprint_name,
|
| 314 |
+
duration_sec,
|
| 315 |
+
platform_name_v,
|
| 316 |
+
style,
|
| 317 |
+
extra_context_v,
|
| 318 |
+
debug_first,
|
| 319 |
+
],
|
| 320 |
+
outputs=[storyboard, script_json, warnings_box],
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
return demo
|