From f26e256e7e203245d9471dd62bad19b5a3bab0df Mon Sep 17 00:00:00 2001 From: Farouk Adeleke Date: Sun, 19 Oct 2025 19:17:58 -0400 Subject: [PATCH] Complete Migration --- README.md | 217 +++++++++++++++++- agent.json | 99 ++++++++ agent.py | 172 ++++++++++++++ agent/__init__.py | 0 agent/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 171 bytes agent/__pycache__/agent.cpython-310.pyc | Bin 0 -> 4080 bytes agent/__pycache__/constants.cpython-310.pyc | Bin 0 -> 2398 bytes agent/__pycache__/helpers.cpython-310.pyc | Bin 0 -> 2501 bytes .../__pycache__/hill_climbing.cpython-310.pyc | Bin 0 -> 2379 bytes agent/__pycache__/models.cpython-310.pyc | Bin 0 -> 3268 bytes agent/__pycache__/modules.cpython-310.pyc | Bin 0 -> 5095 bytes agent/__pycache__/utils.cpython-310.pyc | Bin 0 -> 5557 bytes agent/agent.py | 146 ++++++++++++ agent/constants.py | 75 ++++++ agent/helpers.py | 85 +++++++ agent/hill_climbing.py | 119 ++++++++++ agent/models.py | 60 +++++ agent/modules.py | 128 +++++++++++ agent/utils.py | 192 ++++++++++++++++ auto_classes.json | 4 + config.json | 12 + constants.py | 75 ++++++ helpers.py | 85 +++++++ hill_climbing.py | 119 ++++++++++ main.py | 49 ++++ models.py | 60 +++++ modules.py | 128 +++++++++++ pyproject.toml | 18 ++ utils.py | 192 ++++++++++++++++ 29 files changed, 2034 insertions(+), 1 deletion(-) create mode 100644 agent.json create mode 100644 agent.py create mode 100644 agent/__init__.py create mode 100644 agent/__pycache__/__init__.cpython-310.pyc create mode 100644 agent/__pycache__/agent.cpython-310.pyc create mode 100644 agent/__pycache__/constants.cpython-310.pyc create mode 100644 agent/__pycache__/helpers.cpython-310.pyc create mode 100644 agent/__pycache__/hill_climbing.cpython-310.pyc create mode 100644 agent/__pycache__/models.cpython-310.pyc create mode 100644 agent/__pycache__/modules.cpython-310.pyc create mode 100644 agent/__pycache__/utils.cpython-310.pyc create mode 100644 agent/agent.py create mode 100644 agent/constants.py create mode 100644 agent/helpers.py create mode 100644 agent/hill_climbing.py create mode 100644 agent/models.py create mode 100644 agent/modules.py create mode 100644 agent/utils.py create mode 100644 auto_classes.json create mode 100644 config.json create mode 100644 constants.py create mode 100644 helpers.py create mode 100644 hill_climbing.py create mode 100644 main.py create mode 100644 models.py create mode 100644 modules.py create mode 100644 pyproject.toml create mode 100644 utils.py diff --git a/README.md b/README.md index eb52c1c..c57dff7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,217 @@ -# tweet-optimizer-v2 +# DSPy Tweet Optimizer - Modaic Agent +A composable DSPy agent that optimizes tweets using a reflective generate-evaluate algorithim, packaged for the [Modaic Hub](https://modaic.dev). Generate, evaluate, and iteratively improve tweets with configurable evaluation categories and automated optimization. + +## Features + +- **Modaic Agent**: Deployable on Modaic Hub for easy sharing and reuse +- **Hill-Climbing Optimization**: Iteratively improves tweets through automated evaluation +- **Customizable Categories**: Define evaluation criteria (engagement, clarity, tone, etc.) +- **Multiple Usage Modes**: Single generation, full optimization, or standalone evaluation +- **Structured Evaluation**: 1-9 scoring with detailed reasoning per category +- **CLI Compatibility**: Same functionality as the original CLI tool +- **Easy Configuration**: Flexible model, iteration, and patience settings + +## Installation + +### Prerequisites + +- Python 3.11+ +- OpenRouter API key ([Get one here](https://openrouter.ai/)) +- Modaic account (for hub deployment) + +### Setup + +1. **Clone the repository:** +```bash +git clone https://git.modaic.dev/farouk1/tweet-optimizer-v2.git +cd tweet-optimizer +``` + +2. **Install dependencies:** +```bash +uv sync +``` + +3. **Set up your API key and Modaic Token:** +```bash +export OPENROUTER_API_KEY='your-api-key-here' +export MODAIC_TOKEN='your-modaic-token' +``` + +## Usage + +### Basic Agent Usage + +```python +from tweet_optimizer_agent import TweetOptimizerAgent, TweetOptimizerConfig + +# Create agent with default settings +config = TweetOptimizerConfig() +agent = TweetOptimizerAgent(config) + +# Single tweet generation +tweet = agent( + input_text="Create a tweet about HuggingFace transformers", + current_tweet="", + previous_evaluation=None +) +print(f"Generated: {tweet}") +``` + +### Full Optimization Process + +```python +# Run complete optimization (like CLI) +results = agent.optimize( + input_text="Create a tweet about HuggingFace transformers", + iterations=10, + patience=5 +) + +print(f"Original: {results['initial_text']}") +print(f"Optimized: {results['final_tweet']}") +print(f"Score: {results['best_score']:.2f}") +print(f"Iterations: {results['iterations_run']}") +``` + +### Custom Configuration + +```python +# Custom evaluation categories and settings +config = TweetOptimizerConfig( + lm="openrouter/anthropic/claude-sonnet-4.5", + categories=[ + "Engagement potential", + "Clarity and readability", + "Professional tone", + "Call-to-action strength" + ], + max_iterations=15, + patience=8 +) + +agent = TweetOptimizerAgent(config) +``` + +### Tweet Evaluation + +```python +# Evaluate a specific tweet +evaluation = agent.evaluate_tweet( + tweet_text="Excited to share our new AI model!", + original_text="We released a new AI model", + current_best_tweet="" +) + +for eval in evaluation.evaluations: + print(f"{eval.category}: {eval.score}/9 - {eval.reasoning}") +``` + +### Deploy to Modaic Hub + +```python +# Push your trained agent to Modaic Hub +agent.push_to_hub( + "your-username/tweet-optimizer", + commit_message="Deploy tweet optimizer agent", + with_code=True +) +``` + +### Load from Hub + +```python +# Load a pre-trained agent from Modaic Hub +agent = TweetOptimizerAgent.from_precompiled("your-username/tweet-optimizer") + +# Use immediately +optimized = agent("Your tweet content here") +``` + +## CLI Tool + +The original CLI functionality is still available: + +```bash +# Basic usage +python cli.py "Create a tweet about AI breakthroughs" + +# With custom settings +python cli.py "Product launch announcement" \ + --model "Claude Sonnet 4.5" \ + --iterations 15 \ + --patience 8 \ + --categories "Excitement" "Clarity" "Call-to-action" + +# List available models +python cli.py --list-models + +# Quiet mode (output only final tweet) +python cli.py "Content here" --quiet +``` + +## Configuration Options + +### TweetOptimizerConfig + +| Parameter | Type | Default | Description | +|------------------|-----------|----------------------------------------|---------------------------------------------| +| `lm` | str | `"openrouter/google/gemini-2.5-flash"` | Language model to use | +| `eval_lm` | str | `"openrouter/openai/gpt-5"` | Evaluator language model to use | +| `categories` | List[str] | Default evaluation categories | Custom evaluation criteria | +| `max_iterations` | int | 10 | Maximum optimization iterations | +| `patience` | int | 5 | Stop after N iterations without improvement | + +### Default Categories + +1. **Engagement potential** - How likely users are to like, retweet, or reply +2. **Clarity and readability** - How easy the tweet is to understand +3. **Emotional impact** - How well the tweet evokes feelings or reactions +4. **Relevance to target audience** - How well it resonates with intended readers + +## Architecture + +``` +dspy-tweet-optimizer/ + tweet_optimizer_agent.py # Main Modaic agent implementation + cli.py # Command-line interface + modules.py # DSPy generator and evaluator modules + hill_climbing.py # Optimization algorithm + models.py # Pydantic data models + helpers.py # Utility functions + utils.py # File I/O and DSPy utilities + constants.py # Configuration constants + tests/ # Test suite +``` + +### Core Components + +- **TweetOptimizerAgent**: Main Modaic agent with optimization methods +- **TweetGeneratorModule**: DSPy module for generating/improving tweets +- **TweetEvaluatorModule**: DSPy module for structured evaluation +- **HillClimbingOptimizer**: Iterative improvement algorithm + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Credits + +This Modaic agent implementation is based on the original DSPy Tweet Optimizer by [tom-doerr](https://github.com/tom-doerr/dspy-tweet-optimizer), licensed under MIT. The original project provided the foundation including: + +- Core DSPy modules (TweetGeneratorModule, TweetEvaluatorModule) +- Hill-climbing optimization algorithm +- CLI interface and utilities +- Comprehensive testing framework + +**Original Author**: Tom Doerr ([@tom-doerr](https://github.com/tom-doerr)) +**Original Repository**: [dspy-tweet-optimizer](https://github.com/tom-doerr/dspy-tweet-optimizer) + +### Modifications for Modaic + +- Packaged as a Modaic PrecompiledAgent +- Added hub deployment functionality +- Enhanced configuration options +- Maintained CLI compatibility +- Extended usage examples \ No newline at end of file diff --git a/agent.json b/agent.json new file mode 100644 index 0000000..7cb934d --- /dev/null +++ b/agent.json @@ -0,0 +1,99 @@ +{ + "tweet_generator.generate.predict": { + "traces": [], + "train": [], + "demos": [], + "signature": { + "instructions": "Generate or improve a tweet based on input text and detailed evaluation feedback with reasoning.", + "fields": [ + { + "prefix": "Input Text:", + "description": "Original text or current tweet to improve" + }, + { + "prefix": "Current Tweet:", + "description": "Current best tweet version (empty for first generation)" + }, + { + "prefix": "Previous Evaluation:", + "description": "Previous evaluation with category-by-category reasoning and scores (empty for first generation)" + }, + { + "prefix": "Reasoning: Let's think step by step in order to", + "description": "${reasoning}" + }, + { + "prefix": "Improved Tweet:", + "description": "Generated or improved tweet text (max 280 characters)" + } + ] + }, + "lm": { + "model": "openrouter/google/gemini-2.5-flash", + "model_type": "chat", + "cache": true, + "num_retries": 3, + "finetuning_model": null, + "launch_kwargs": {}, + "train_kwargs": {}, + "temperature": 0.7, + "max_tokens": 4096, + "api_key": "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", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tweet_evaluator.evaluate.predict": { + "traces": [], + "train": [], + "demos": [], + "signature": { + "instructions": "Evaluate a tweet across multiple custom categories. For each category, provide detailed reasoning explaining the score, then assign a score. Ensure the tweet maintains the same meaning as the original text.", + "fields": [ + { + "prefix": "Original Text:", + "description": "Original input text that started the optimization" + }, + { + "prefix": "Current Best Tweet:", + "description": "Current best tweet version for comparison (empty for first evaluation)" + }, + { + "prefix": "Tweet Text:", + "description": "Tweet text to evaluate" + }, + { + "prefix": "Categories:", + "description": "Comma-separated list of evaluation category descriptions" + }, + { + "prefix": "Reasoning: Let's think step by step in order to", + "description": "${reasoning}" + }, + { + "prefix": "Evaluations:", + "description": "List of evaluations with category name, detailed reasoning, and score (1-9) for each category. Ensure the tweet conveys the same meaning as the original text." + } + ] + }, + "lm": { + "model": "openrouter/openai/gpt-5", + "model_type": "chat", + "cache": true, + "num_retries": 3, + "finetuning_model": null, + "launch_kwargs": {}, + "train_kwargs": {}, + "temperature": 1.0, + "max_completion_tokens": 16000, + "api_key": "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", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "metadata": { + "dependency_versions": { + "python": "3.13", + "dspy": "3.0.3", + "cloudpickle": "3.1" + } + } +} diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..4d48c4b --- /dev/null +++ b/agent.py @@ -0,0 +1,172 @@ +from modaic import PrecompiledAgent, PrecompiledConfig +from modules import TweetGeneratorModule, TweetEvaluatorModule +from models import EvaluationResult +from hill_climbing import HillClimbingOptimizer +from typing import Optional, List, Dict, Any +from utils import get_dspy_lm +from constants import DEFAULT_CATEGORIES, DEFAULT_ITERATIONS, DEFAULT_PATIENCE + + +class TweetOptimizerConfig(PrecompiledConfig): + lm: str = "openrouter/google/gemini-2.5-flash" + eval_lm: str = "openrouter/openai/gpt-5" + categories: List[str] = DEFAULT_CATEGORIES + max_iterations: int = DEFAULT_ITERATIONS + patience: int = DEFAULT_PATIENCE + + +class TweetOptimizerAgent(PrecompiledAgent): + config: TweetOptimizerConfig + + def __init__(self, config: TweetOptimizerConfig): + super().__init__(config) + self.tweet_generator = TweetGeneratorModule() + self.tweet_evaluator = TweetEvaluatorModule() + + # set up optimizer + self.optimizer = HillClimbingOptimizer( + generator=self.tweet_generator, + evaluator=self.tweet_evaluator, + categories=config.categories, + max_iterations=config.max_iterations, + patience=config.patience + ) + + self.lm = config.lm + self.eval_lm = config.eval_lm + + # initialize DSPy with the specified model + self.tweet_generator.set_lm(get_dspy_lm(config.lm)) + self.tweet_evaluator.set_lm(get_dspy_lm(config.eval_lm)) + + def forward( + self, + input_text: str, + current_tweet: str = "", + previous_evaluation: Optional[EvaluationResult] = None, + ) -> str: + """Generate a single optimized tweet (single iteration).""" + tweet = self.tweet_generator(input_text, current_tweet, previous_evaluation) + return tweet + + def optimize( + self, + input_text: str, + iterations: Optional[int] = None, + patience: Optional[int] = None + ) -> Dict[str, Any]: + """Run full optimization process like the CLI.""" + max_iterations = iterations or self.config.max_iterations + patience_limit = patience or self.config.patience + + results = { + 'initial_text': input_text, + 'final_tweet': '', + 'best_score': 0.0, + 'iterations_run': 0, + 'early_stopped': False, + 'scores_history': [], + 'improvement_count': 0 + } + + best_tweet = "" + best_score = 0.0 + + for iteration, (current_tweet, scores, is_improvement, patience_counter, _, _) in enumerate( + self.optimizer.optimize(input_text) + ): + iteration_num = iteration + 1 + results['iterations_run'] = iteration_num + results['scores_history'].append(scores) + + if is_improvement: + best_tweet = current_tweet + best_score = sum(scores.category_scores) / len(scores.category_scores) + results['improvement_count'] += 1 + + # check for early stopping + if patience_counter >= patience_limit: + results['early_stopped'] = True + break + + # stop at max iterations + if iteration_num >= max_iterations: + break + + results.update({ + 'final_tweet': best_tweet, + 'best_score': best_score + }) + + return results + + def evaluate_tweet( + self, + tweet_text: str, + original_text: str = "", + current_best_tweet: str = "" + ) -> EvaluationResult: + """Evaluate a tweet using the configured categories.""" + return self.tweet_evaluator(tweet_text, self.config.categories, original_text, current_best_tweet) + + +if __name__ == "__main__": + # create agent with default config + config = TweetOptimizerConfig() + tweet_optimizer = TweetOptimizerAgent(config) + """ + import os + + # set up test environment (replace with real API key for actual usage) + if not os.getenv("OPENROUTER_API_KEY"): + raise ValueError("OPENROUTER_API_KEY environment variable is not set") + + + + # single tweet generation + print("=== Single Tweet Generation ===") + try: + single_tweet = tweet_optimizer( + input_text="Anthropic added a new OSS model on HuggingFace.", + current_tweet="", + previous_evaluation=None, + ) + print(f"Generated tweet: {single_tweet}") + except Exception as e: + print(f"Error in single generation: {e}") + + # full optimization process + print("\n=== Full Optimization Process ===") + try: + results = tweet_optimizer.optimize( + input_text="Anthropic added a new OSS model on HuggingFace.", + iterations=10, # Reduced for testing + patience=8 + ) + print(f"Initial text: {results['initial_text']}") + print(f"Final tweet: {results['final_tweet']}") + print(f"Best score: {results['best_score']:.2f}") + print(f"Iterations run: {results['iterations_run']}") + print(f"Improvements found: {results['improvement_count']}") + print(f"Early stopped: {results['early_stopped']}") + except Exception as e: + print(f"Error in optimization: {e}") + """ + # push to hub + print("\n=== Push to Hub ===") + try: + tweet_optimizer.push_to_hub( + "farouk1/tweet-optimizer-v2", + commit_message="Complete Migration", + with_code=True + ) + print("Successfully pushed to hub!") + except Exception as e: + print(f"Error pushing to hub: {e}") + """ + print("\n=== Agent Configuration ===") + print(f"Model: {config.lm}") + print(f"Categories: {config.categories}") + print(f"Max iterations: {config.max_iterations}") + print(f"Patience: {config.patience}") + """ \ No newline at end of file diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/__pycache__/__init__.cpython-310.pyc b/agent/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..992044a3f79d05b6054fd4e71ab99e0c7ac4b1bc GIT binary patch literal 171 zcmd1j<>g`k0^Z=S86f&Gh(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o6vHKeRZts8~NO zF(oxeUoSZ^IU`j+H$NpYGg&_|JvFZcBvq7Onr*0GQl6SxqMKh(l9`)Xm0F}*W&}~8 hA0MBYmst`YuUAlci^B$}xilx$4rEU;6OdqG003e5DWm`Z literal 0 HcmV?d00001 diff --git a/agent/__pycache__/agent.cpython-310.pyc b/agent/__pycache__/agent.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0af2856bc61267f5d545e989f0725be364850fd7 GIT binary patch literal 4080 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 BoY4RP literal 0 HcmV?d00001 diff --git a/agent/__pycache__/hill_climbing.cpython-310.pyc b/agent/__pycache__/hill_climbing.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f66105c94857ea9afa637178076f538290d45305 GIT binary patch literal 2379 zcmZ`*-H+8o6rY(+Kkvt07Em`vY>FN97S#XRw@H z-Vc*SSjKsFl^2Vo^oTrl{VwO_6`nP6U*v27Vhwk7JQocYG`u~_MH-d??-T{V33giX zNO{vABUoI3ugHLK!mzK|#Man^QBIl8b!Kpbn;Q@rx0uDPnZs=69O}N!2Ha#r z?r`|!Lq6gbAH&R?kqvY_n;9Eu1I;KKvGF1G*Vr0hb3&f}G)|I>Nu1t_vz;&JWt_$b zTpVmEwi~VZZr*OV5Q|$XJlr^)i+rD_JS(@3)D<7;K&0J8D(w{pfVC5aCEv+K%nLbA z!+Swo-vYo~NNWyw^DN?#0Selo<^;Zr@D=X>sc?k?R1u)K(twhR0@W%VC}jx3!P}lL z#&A}wDKb&ygd&i4$TK7@crttCfnSg~1?L`Ln|@tzQA}qcX@3Zq>-8;(KLkU zO0^~O#qIZ}H4BFr^0T%C%=VhK#>gXWsj0n6 zV-)rP!@U~MJyJ{PkLXgbw53t$OOqK(3$WIiSy_NQof+RCj7p7JAVnXNOX%CfrG4;K zsaNJhyma7>jLPZTgtb}Q9MpS*p3MR10GpM;l+9r^tjybVIjTk?uLfX$xUxT}hR@s2 zMirF6hX{OwQ_SxB26MD$+}6bpH^5V-T|+m}opThG=KbF=T8=R~c(;>Q-Wn^9l%Frx z^y>|D6ErQy-+^?av0dLe*YNM+yY-Ie9Q@(9tI<}|s$LcaRG6z06IPt>)=F#LvD|#-z85WofK&|X4BT{k-A2@XYtxO3pjV=sZd+wRlrJ)8Q zR7lPFqAWT!(61Vf*IAdy)1*^#n=BkX=Yc#q7kod?7e%k}ZG*@N;@gR{Hh0%ssaw2Q zIWL_$hFYsf#oLgw(;Ndgt0r{K${;DR{I=$@? zF$AB5dhfi6YKU2w@*t2-5I}c@Hx=f?Ah@##lhuwH1T2q&Kv+spbxxt0p45s`NG;Av z@tWeP{~!ca#8kWVR-Pw9Jx0P)L^bsuQCJGqD^)%fE)WX=Pyic!459rC@H{mrc|vsZ z4>6p_=g0NwZEqxv^8Or(xOSpvwSM;uh=C_X-!IPn`-|6 literal 0 HcmV?d00001 diff --git a/agent/__pycache__/models.cpython-310.pyc b/agent/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f2dbae892a678eda29d650fd2ccb99f78c1f5d55 GIT binary patch literal 3268 zcmbVO%WvF77@rw?y{}}mY{Mg^fI&dJg>FiPfJ0OsAyk!UKm;me30baZlDP5O+nL#x zSV003apST?jBaGH`n4QQVQtnUO4#W9w;{Yp@Qw!_`j>sNznzZTRexj}e^dq;$O zf*v|S9r{&XgMQ8S8@#?nx{cppXVP^{{ewt}TR9i0seF`(l$-kfC{1{zbJ?ZF{Wwwj zH;TH<)NkF~47b)l`D_E4?|)e~W87%=@u2e_yy`9pK?2H2z`g({0!O%t0 z++8CuQn4d7JL1`qs;ECia~mM*k1C=r8ocqN6EwvFtZIVJB5xj2zQ7lc$RQ0{yag^T zna+Bo#ZE5wH^7Bqq?0@=-u-l+N10AyzmHD(+qv{3UnSX2D*Sk)?~BO-|3RX6eJLW9 zXQ0$8E_}W#X6GksvZybPscCYdVwnu^G*cVT7AqTH57H>JM%Y6&Y@^~_3R9o#UaV~4 z$Y?%MQ-dxUdgV{r?K-jvQ8a8v7?uiCu{*>J#>Nk}tMIPFtKI>j$pO{$kjX{(vOS{k z>+HGmymmP~pb%818AwMv56L6%kbV!N5J0!MxRI$L*ke!e!5$1olKE)6tqNa8*^cNH zUTJr6!S4^1_U{Nk%KRjQ=nDxw{XmG!FDfv~{cgoH5(v1`Q5Fm1fSN>uWetRB+=f(% z4Jo1Am~rA%$qtMk6ZtfJ{``3L3nipl-Hsr6tGzgicf~4XGD_mrXh&okr({0ddt((3 z)mL&zL_aBnymJ3qse$SuRlUK!X;~*A>7n(o4JRlp*YnPK>eAxH`TFUdT(JpNRtrN+ zeHfZX7{cDe6x$17cyAb`qnTA>!C0fcdsmS!moqb$p{Eei$ni#R`thIL&o z;}}waTtR_xm=Vul7f6Ft;8jn7Xb_inScfv&hL_RSU!K>ZR%Z^J6J{x3;p4!$LPX61 zw9Z|Cnq?G#T0=B>6`;DXLbe&N@%jL7TVu(h&)s;BEc{ zd^@}YwRX<5ry%`Is9~y$*Ux~eP3kQCq}5fu;w@w*Ki{5WW(wl6aHlA?b!bzrBr>mQ z&gjme2@z${bO9O%RMrqt5CgggsnnFS31XZHVjc*8H@Xin(0&Ts0fuF>tkIKLX2s=O zh?aT9F>A~?ieN}ZCZB|LT_!Qlrh-Hnk>tQ^ zd4B*^e-r_!4Y%%);>|hl#}O}yK=szrJcig_hsJn=E|o9Ba-euuiFEt)QA?+`cx8NE z+bR72I`4%47-00@)*OOgPeV*G#G@czU60aum;!xSLe@Yd>4>VMSmsLkQJRk7kO(y& zWekVH7t858jIvio`8J{C9^;ULF5Q%`!mPZAV#+;8JEpz{lqY{#pyxYe+4<11e~)HmGnVUqy>wLV*QxrUQ$ktwtdLwXzmj@%)?%CsgOyXi^yn>G9pJ zsv1xME;+vdH=*2fF?Yq)b;KE5;bMY!ph2Wp<==rnJr2a=&s0wD*p=td2_J-MZRYxB ze~^kk+^m9IM~|7rWSdG3e*yAzMR;Mjqr>nTX#9>8*I+K(LdDZ_PL_KA+dJ?UynA_l zSHye1z?&9m58YACCajJa5SaB~5@h3^X4dP5QQDd`vPM$}tXHnoaAfH2lxhQjv zm#@8H20&+&ObnbIz9jZ literal 0 HcmV?d00001 diff --git a/agent/__pycache__/modules.cpython-310.pyc b/agent/__pycache__/modules.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1de1ab06d195c95e56dc5c7b48d0642c240fa494 GIT binary patch literal 5095 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�J2!K&?N4Ur_^{N;Q=BIl+QrkV;Qc>Ao_Q zO8;jHPlQ}k_uTgCfV$_^sHX0P|C+j=)rqrzi~mjbj2+A#y~*dW)&toi-z420Q=g-1 zAG!2)>y1AwABs93_U-v^&^V$V(xf1=p0$Ul`X_i9=bGYiO*h;}!^kc4Ea8c6LG&0M z(we0^J!~Qf88P%DbVT;R-+@bP6S|{>R3>dD&L&%)1p!0pm-w4z2U^N1-v$H7yZ9_} z?)ok!0On2M3U=EMn3vtq$2AlW4h@u_xDgRA;#mHpR=`QiJ0MJSZH(r1Z%LPOGrFov zG(f+%tL{=qN?c1*1x~$44Xg$`07ktx7vdh6kk9$RP#4zpe*{B=+--AAg753w)$DL@ zWkhLkpSUkJgC;FW_f$y_UHtD5wjPP(*d-vs%}l%6N7e7`Y_UQVM5X-FEZewPWSckMo&?y(^z$NKQ zncg`y^6LQ3kql}Mp+6Op>*oxW^Z}r8MF+ZOsYE(-$R}6Vx2SrZ zM3uw}2_kN>MSoHdYtsB8Ia&zdHCReQ8TknbTIb-Uf1a8sc4|qx$ON7!D}x gH2WEwohv%y<0of|b49yoAJ5D>vy-#AqH}iapD=R?hyVZp literal 0 HcmV?d00001 diff --git a/agent/agent.py b/agent/agent.py new file mode 100644 index 0000000..c88cdb3 --- /dev/null +++ b/agent/agent.py @@ -0,0 +1,146 @@ +from modaic import PrecompiledAgent, PrecompiledConfig +from .modules import TweetGeneratorModule, TweetEvaluatorModule +from .models import EvaluationResult +from .hill_climbing import HillClimbingOptimizer +from typing import Optional, List +from .utils import get_dspy_lm +from .constants import DEFAULT_CATEGORIES, DEFAULT_ITERATIONS, DEFAULT_PATIENCE + + +class TweetOptimizerConfig(PrecompiledConfig): + lm: str = "openrouter/google/gemini-2.5-flash" + eval_lm: str = "openrouter/openai/gpt-5" + categories: List[str] = DEFAULT_CATEGORIES + max_iterations: int = DEFAULT_ITERATIONS + patience: int = DEFAULT_PATIENCE + + +class TweetOptimizerAgent(PrecompiledAgent): + config: TweetOptimizerConfig + + current_tweet: str = "" + previous_evaluation: Optional[EvaluationResult] = None + + def __init__(self, config: TweetOptimizerConfig, **kwargs): + super().__init__(config, **kwargs) + self.tweet_generator = TweetGeneratorModule() + self.tweet_evaluator = TweetEvaluatorModule() + + # set up optimizer + self.optimizer = HillClimbingOptimizer( + generator=self.tweet_generator, + evaluator=self.tweet_evaluator, + categories=config.categories, + max_iterations=config.max_iterations, + patience=config.patience + ) + + self.lm = config.lm + self.eval_lm = config.eval_lm + + # initialize DSPy with the specified model + self.tweet_generator.set_lm(get_dspy_lm(config.lm)) + self.tweet_evaluator.set_lm(get_dspy_lm(config.eval_lm)) + + def forward( + self, + input_text: str, + iterations: Optional[int] = None, + patience: Optional[int] = None + ) -> str: + """Run full optimization process.""" + max_iterations = iterations or self.config.max_iterations + patience_limit = patience or self.config.patience + + results = { + 'initial_text': input_text, + 'final_tweet': '', + 'best_score': 0.0, + 'iterations_run': 0, + 'early_stopped': False, + 'scores_history': [], + 'improvement_count': 0 + } + + best_tweet = "" + best_score = 0.0 + + for iteration, (current_tweet, scores, is_improvement, patience_counter, _, _) in enumerate( + self.optimizer.optimize(input_text) + ): + iteration_num = iteration + 1 + results['iterations_run'] = iteration_num + results['scores_history'].append(scores) + + if is_improvement: + best_tweet = current_tweet + best_score = sum(scores.category_scores) / len(scores.category_scores) + results['improvement_count'] += 1 + + # check for early stopping + if patience_counter >= patience_limit: + results['early_stopped'] = True + break + + # stop at max iterations + if iteration_num >= max_iterations: + break + + results.update({ + 'final_tweet': best_tweet, + 'best_score': best_score + }) + + self.reset() + + return results + + def reset(self): + self.current_tweet = "" + self.previous_evaluation = None + + +if __name__ == "__main__": + # create agent with default config + config = TweetOptimizerConfig() + tweet_optimizer = TweetOptimizerAgent(config) + import os + + # set up test environment (replace with real API key for actual usage) + if not os.getenv("OPENROUTER_API_KEY"): + raise ValueError("OPENROUTER_API_KEY environment variable is not set") + + # full optimization process + print("\n=== Full Optimization Process ===") + try: + results = tweet_optimizer( + input_text="Anthropic added a new OSS model on HuggingFace.", + iterations=10, # Reduced for testing + patience=8 + ) + print(f"Initial text: {results['initial_text']}") + print(f"Final tweet: {results['final_tweet']}") + print(f"Best score: {results['best_score']:.2f}") + print(f"Iterations run: {results['iterations_run']}") + print(f"Improvements found: {results['improvement_count']}") + print(f"Early stopped: {results['early_stopped']}") + except Exception as e: + print(f"Error in optimization: {e}") + + # push to hub + print("\n=== Push to Hub ===") + try: + tweet_optimizer.push_to_hub( + "farouk1/tweet-optimizer-v2", + commit_message="Complete Migration", + with_code=True + ) + print("Successfully pushed to hub!") + except Exception as e: + print(f"Error pushing to hub: {e}") + + print("\n=== Agent Configuration ===") + print(f"Model: {config.lm}") + print(f"Categories: {config.categories}") + print(f"Max iterations: {config.max_iterations}") + print(f"Patience: {config.patience}") \ No newline at end of file diff --git a/agent/constants.py b/agent/constants.py new file mode 100644 index 0000000..1eb4de6 --- /dev/null +++ b/agent/constants.py @@ -0,0 +1,75 @@ +from typing import Dict, List + +# tweet configuration +TWEET_MAX_LENGTH = 280 +TWEET_TRUNCATION_SUFFIX = "..." +TWEET_TRUNCATION_LENGTH = TWEET_MAX_LENGTH - len(TWEET_TRUNCATION_SUFFIX) + +# score configuration +MIN_SCORE = 1 +MAX_SCORE = 9 +DEFAULT_SCORE = 5 + +# file paths +CATEGORIES_FILE = "categories.json" +SETTINGS_FILE = "settings.json" +HISTORY_FILE = "input_history.json" + +# history configuration +MAX_HISTORY_ITEMS = 50 # maximum number of historical inputs to store + +# model configuration +DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5" + +AVAILABLE_MODELS: Dict[str, str] = { + "Claude Sonnet 4.5": "openrouter/anthropic/claude-sonnet-4.5", + "Opus 4.1": "openrouter/anthropic/claude-opus-4.1", + "Gemini 2.5 Flash": "openrouter/google/gemini-2.5-flash", + "Gemini 2.5 Flash Lite": "openrouter/google/gemini-2.5-flash-lite", + "Gemini 2.5 Pro": "openrouter/google/gemini-2.5-pro", + "GPT-5": "openrouter/openai/gpt-5" +} + +# openrouter API configuration +OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" +OPENROUTER_MAX_TOKENS = 4096 +OPENROUTER_TEMPERATURE = 0.7 + +# optimization defaults +DEFAULT_ITERATIONS = 10 +DEFAULT_PATIENCE = 5 +DEFAULT_USE_CACHE = True + +# default evaluation categories +DEFAULT_CATEGORIES: List[str] = [ + "Engagement potential - how likely users are to like, retweet, or reply", + "Clarity and readability - how easy the tweet is to understand", + "Emotional impact - how well the tweet evokes feelings or reactions", + "Relevance to target audience - how well it resonates with intended readers" +] + +# error messages +ERROR_PARSING = "Default evaluation due to parsing error" +ERROR_VALIDATION = "Default evaluation due to validation error" +ERROR_GENERATION = "Tweet generation failed" +ERROR_EVALUATION = "Tweet evaluation failed" +ERROR_DSPy_INIT = "DSPy initialization failed" +ERROR_NO_API_KEY = "OPENROUTER_API_KEY environment variable is required" +ERROR_SAVE_CATEGORIES = "Failed to save categories" +ERROR_LOAD_CATEGORIES = "Failed to load categories" +ERROR_SAVE_SETTINGS = "Failed to save settings" +ERROR_LOAD_SETTINGS = "Failed to load settings" +ERROR_SAVE_HISTORY = "Failed to save input history" +ERROR_LOAD_HISTORY = "Failed to load input history" + +# cache configuration +CACHE_ENABLE_MEMORY = True +CACHE_ENABLE_DISK = True + +# iteration display +ITERATION_SLEEP_TIME = 0.1 # seconds + +# truncation display +CATEGORY_DISPLAY_MAX_LENGTH = 30 +CATEGORY_DISPLAY_TRUNCATION = "..." +CATEGORY_IMPROVEMENT_MAX_LENGTH = 50 diff --git a/agent/helpers.py b/agent/helpers.py new file mode 100644 index 0000000..90dd1ff --- /dev/null +++ b/agent/helpers.py @@ -0,0 +1,85 @@ +from typing import Optional, Dict, Any +from .models import EvaluationResult +from .constants import MAX_SCORE + + +def format_evaluation_for_generator(evaluation: Optional[EvaluationResult]) -> str: + """ + Format an evaluation result as text for the generator module. + + Args: + evaluation: The evaluation result to format + + Returns: + Formatted string with category-by-category reasoning and scores + """ + if not evaluation or not evaluation.evaluations: + return "" + + eval_lines = [] + for eval in evaluation.evaluations: + eval_lines.append(f"{eval.category} (Score: {eval.score}/{MAX_SCORE}): {eval.reasoning}") + + return "\n".join(eval_lines) + + +def build_settings_dict( + selected_model: str, + iterations: int, + patience: int, + use_cache: bool +) -> Dict[str, Any]: + """ + Build a settings dictionary for saving. + + Args: + selected_model: The selected model name + iterations: Number of optimization iterations + patience: Patience threshold for early stopping + use_cache: Whether to use DSPy cache + + Returns: + Dictionary containing all settings + """ + return { + "selected_model": selected_model, + "iterations": iterations, + "patience": patience, + "use_cache": use_cache + } + + +def truncate_tweet(tweet: str, max_length: int, suffix: str = "...") -> str: + """ + Truncate a tweet to the maximum length with a suffix. + + Args: + tweet: The tweet text to truncate + max_length: Maximum allowed length + suffix: Suffix to add when truncating (default: "...") + + Returns: + Truncated tweet text + """ + tweet = tweet.strip() + if len(tweet) <= max_length: + return tweet + + truncation_point = max_length - len(suffix) + return tweet[:truncation_point] + suffix + + +def truncate_category_display(category: str, max_length: int = 30) -> str: + """ + Truncate a category name for display purposes. + + Args: + category: The category name + max_length: Maximum display length (default: 30) + + Returns: + Truncated category name with "..." if needed + """ + if len(category) <= max_length: + return category + return category[:max_length] + "..." diff --git a/agent/hill_climbing.py b/agent/hill_climbing.py new file mode 100644 index 0000000..e4ee9a8 --- /dev/null +++ b/agent/hill_climbing.py @@ -0,0 +1,119 @@ +from typing import List, Iterator, Tuple, Dict +from .models import EvaluationResult +from .modules import TweetGeneratorModule, TweetEvaluatorModule +from .helpers import format_evaluation_for_generator + +class HillClimbingOptimizer: + """Hill climbing optimizer for tweet improvement.""" + + def __init__( + self, + generator: TweetGeneratorModule, + evaluator: TweetEvaluatorModule, + categories: List[str], + max_iterations: int = 10, + patience: int = 5 + ): + self.generator = generator + self.evaluator = evaluator + self.categories = categories + self.max_iterations = max_iterations + self.patience = patience + + def optimize(self, initial_text: str) -> Iterator[Tuple[str, EvaluationResult, bool, int, Dict[str, str], Dict[str, str]]]: + """ + Optimize tweet using hill climbing algorithm. + + Yields: + Tuple of (current_tweet, evaluation_result, is_improvement, patience_counter, generator_inputs, evaluator_inputs) + """ + # Generate initial tweet + generator_inputs = { + "input_text": initial_text, + "current_tweet": "", + "previous_evaluation": "" + } + current_tweet = self.generator( + input_text=initial_text, + current_tweet="", + previous_evaluation=None + ) + + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": "", + "tweet_text": current_tweet + } + current_score = self.evaluator( + tweet_text=current_tweet, + categories=self.categories, + original_text=initial_text, + current_best_tweet="" + ) + + best_tweet = current_tweet + best_score = current_score + patience_counter = 0 + + yield (current_tweet, current_score, True, patience_counter, generator_inputs, evaluator_inputs) + + for iteration in range(1, self.max_iterations): + # Generate improved tweet with previous evaluation as feedback + try: + # Format evaluation for display in generator inputs + eval_text = format_evaluation_for_generator(best_score) + + generator_inputs = { + "input_text": initial_text, + "current_tweet": best_tweet, + "previous_evaluation": eval_text + } + + candidate_tweet = self.generator( + input_text=initial_text, + current_tweet=best_tweet, + previous_evaluation=best_score + ) + + # Evaluate candidate + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": best_tweet, + "tweet_text": candidate_tweet + } + candidate_score = self.evaluator( + tweet_text=candidate_tweet, + categories=self.categories, + original_text=initial_text, + current_best_tweet=best_tweet + ) + + # Check if candidate is better (hill climbing condition) + is_improvement = candidate_score > best_score + + if is_improvement: + best_tweet = candidate_tweet + best_score = candidate_score + patience_counter = 0 + yield (candidate_tweet, candidate_score, True, patience_counter, generator_inputs, evaluator_inputs) + else: + patience_counter += 1 + yield (best_tweet, candidate_score, False, patience_counter, generator_inputs, evaluator_inputs) + + # Early stopping if no improvement for 'patience' iterations + if patience_counter >= self.patience: + break + + except Exception as e: + # If generation fails, yield current best + patience_counter += 1 + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": best_tweet, + "tweet_text": best_tweet + } + yield (best_tweet, best_score, False, patience_counter, generator_inputs, evaluator_inputs) + + if patience_counter >= self.patience: + break + diff --git a/agent/models.py b/agent/models.py new file mode 100644 index 0000000..198c53f --- /dev/null +++ b/agent/models.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field, validator +from typing import List +from .constants import MIN_SCORE, MAX_SCORE + +class CategoryEvaluation(BaseModel): + """Pydantic model for a single category evaluation with reasoning.""" + + category: str = Field(description="The evaluation category name") + reasoning: str = Field(description="Explanation for the score") + score: int = Field( + description=f"Score for this category ({MIN_SCORE}-{MAX_SCORE})", + ge=MIN_SCORE, + le=MAX_SCORE + ) + + @validator('score') + def validate_score(cls, score): + """Ensure score is within the valid range.""" + if not isinstance(score, int) or score < MIN_SCORE or score > MAX_SCORE: + raise ValueError(f"Score {score} must be an integer between {MIN_SCORE} and {MAX_SCORE}") + return score + +class EvaluationResult(BaseModel): + """Pydantic model for tweet evaluation results.""" + + evaluations: List[CategoryEvaluation] = Field( + description="List of category evaluations with reasoning and scores" + ) + + @validator('evaluations') + def validate_evaluations(cls, evals): + """Ensure we have at least one evaluation.""" + if not evals or len(evals) < 1: + raise ValueError("Must have at least one category evaluation") + return evals + + @property + def category_scores(self) -> List[int]: + """Get list of scores for backwards compatibility.""" + return [eval.score for eval in self.evaluations] + + def total_score(self) -> float: + """Calculate the total score across all categories.""" + return sum(eval.score for eval in self.evaluations) + + def average_score(self) -> float: + """Calculate the average score across all categories.""" + return self.total_score() / len(self.evaluations) + + def __gt__(self, other): + """Compare evaluation results based on total score.""" + if not isinstance(other, EvaluationResult): + return NotImplemented + return self.total_score() > other.total_score() + + def __eq__(self, other): + """Check equality based on total score.""" + if not isinstance(other, EvaluationResult): + return NotImplemented + return self.total_score() == other.total_score() diff --git a/agent/modules.py b/agent/modules.py new file mode 100644 index 0000000..c0b0995 --- /dev/null +++ b/agent/modules.py @@ -0,0 +1,128 @@ +import dspy +from typing import List, Optional +from .models import EvaluationResult, CategoryEvaluation +from .constants import ( + TWEET_MAX_LENGTH, + TWEET_TRUNCATION_SUFFIX, + DEFAULT_SCORE, + ERROR_PARSING, + ERROR_VALIDATION, + ERROR_GENERATION, + ERROR_EVALUATION, + MIN_SCORE, + MAX_SCORE +) +from .helpers import format_evaluation_for_generator, truncate_tweet + +class TweetGenerator(dspy.Signature): + """Generate or improve a tweet based on input text and detailed evaluation feedback with reasoning.""" + + input_text: str = dspy.InputField(desc="Original text or current tweet to improve") + current_tweet: str = dspy.InputField(desc="Current best tweet version (empty for first generation)") + previous_evaluation: str = dspy.InputField(desc="Previous evaluation with category-by-category reasoning and scores (empty for first generation)") + improved_tweet: str = dspy.OutputField(desc=f"Generated or improved tweet text (max {TWEET_MAX_LENGTH} characters)") + +class TweetEvaluator(dspy.Signature): + """Evaluate a tweet across multiple custom categories. For each category, provide detailed reasoning explaining the score, then assign a score. Ensure the tweet maintains the same meaning as the original text.""" + + original_text: str = dspy.InputField(desc="Original input text that started the optimization") + current_best_tweet: str = dspy.InputField(desc="Current best tweet version for comparison (empty for first evaluation)") + tweet_text: str = dspy.InputField(desc="Tweet text to evaluate") + categories: str = dspy.InputField(desc="Comma-separated list of evaluation category descriptions") + evaluations: List[CategoryEvaluation] = dspy.OutputField( + desc=f"List of evaluations with category name, detailed reasoning, and score ({MIN_SCORE}-{MAX_SCORE}) for each category. Ensure the tweet conveys the same meaning as the original text." + ) + +class TweetGeneratorModule(dspy.Module): + """DSPy module for generating and improving tweets.""" + + def __init__(self): + super().__init__() + self.generate = dspy.ChainOfThought(TweetGenerator) + + def forward(self, input_text: str, current_tweet: str = "", previous_evaluation: Optional[EvaluationResult] = None) -> str: + """Generate or improve a tweet.""" + try: + # Format previous evaluation as text + eval_text = format_evaluation_for_generator(previous_evaluation) + + result = self.generate( + input_text=input_text, + current_tweet=current_tweet, + previous_evaluation=eval_text + ) + + # Ensure tweet doesn't exceed character limit + tweet = truncate_tweet(result.improved_tweet, TWEET_MAX_LENGTH, TWEET_TRUNCATION_SUFFIX) + + return tweet + except Exception as e: + raise Exception(f"{ERROR_GENERATION}: {str(e)}") + +class TweetEvaluatorModule(dspy.Module): + """DSPy module for evaluating tweets across custom categories.""" + + def __init__(self): + super().__init__() + self.evaluate = dspy.ChainOfThought(TweetEvaluator) + + def forward(self, tweet_text: str, categories: List[str], original_text: str = "", current_best_tweet: str = "") -> EvaluationResult: + """Evaluate a tweet across specified categories.""" + try: + # Join categories into comma-separated string + categories_str = ", ".join(categories) + + result = self.evaluate( + original_text=original_text, + current_best_tweet=current_best_tweet, + tweet_text=tweet_text, + categories=categories_str + ) + + # Extract and validate evaluations + evaluations = result.evaluations + + # Ensure we have the right number of evaluations + if len(evaluations) != len(categories): + # Create default evaluations if mismatch + evaluations = [ + CategoryEvaluation( + category=cat, + reasoning=ERROR_PARSING, + score=DEFAULT_SCORE + ) for cat in categories + ] + else: + # Validate each evaluation + validated_evals = [] + for i, eval in enumerate(evaluations): + try: + # Ensure score is valid + score = max(MIN_SCORE, min(MAX_SCORE, int(eval.score))) + validated_evals.append(CategoryEvaluation( + category=categories[i] if i < len(categories) else eval.category, + reasoning=eval.reasoning if eval.reasoning else "No reasoning provided", + score=score + )) + except (ValueError, TypeError, AttributeError): + validated_evals.append(CategoryEvaluation( + category=categories[i] if i < len(categories) else "Unknown", + reasoning=ERROR_VALIDATION, + score=DEFAULT_SCORE + )) + evaluations = validated_evals + + # Create validated result + validated_result = EvaluationResult(evaluations=evaluations) + + return validated_result + except Exception as e: + # Return default evaluations on error + default_evals = [ + CategoryEvaluation( + category=cat, + reasoning=f"{ERROR_EVALUATION}: {str(e)}", + score=DEFAULT_SCORE + ) for cat in categories + ] + return EvaluationResult(evaluations=default_evals) diff --git a/agent/utils.py b/agent/utils.py new file mode 100644 index 0000000..7ef0f4c --- /dev/null +++ b/agent/utils.py @@ -0,0 +1,192 @@ +import json +import os +import dspy +from typing import List, Dict, Any +from .constants import ( + CATEGORIES_FILE, + SETTINGS_FILE, + HISTORY_FILE, + DEFAULT_CATEGORIES, + DEFAULT_MODEL, + DEFAULT_ITERATIONS, + DEFAULT_PATIENCE, + DEFAULT_USE_CACHE, + MAX_HISTORY_ITEMS, + OPENROUTER_API_BASE, + OPENROUTER_MAX_TOKENS, + OPENROUTER_TEMPERATURE, + ERROR_NO_API_KEY, + ERROR_SAVE_CATEGORIES, + ERROR_LOAD_CATEGORIES, + ERROR_SAVE_SETTINGS, + ERROR_LOAD_SETTINGS, + ERROR_SAVE_HISTORY, + ERROR_LOAD_HISTORY, + ERROR_DSPy_INIT, + TWEET_MAX_LENGTH +) + +def save_categories(categories: List[str]) -> None: + """Save categories to JSON file.""" + try: + with open(CATEGORIES_FILE, 'w') as f: + json.dump(categories, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_CATEGORIES}: {str(e)}") + +def load_categories() -> List[str]: + """Load categories from JSON file.""" + try: + if os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'r') as f: + categories = json.load(f) + return categories if isinstance(categories, list) else [] + else: + save_categories(DEFAULT_CATEGORIES) + return DEFAULT_CATEGORIES + except Exception as e: + print(f"{ERROR_LOAD_CATEGORIES}: {str(e)}") + return [] + +def get_dspy_lm(model_name: str): + """Get a DSPy LM instance for the specified model (cached per model).""" + try: + openrouter_key = os.getenv("OPENROUTER_API_KEY") + if not openrouter_key: + raise ValueError(ERROR_NO_API_KEY) + + max_tokens = 16000 if "openai/gpt-5" in model_name else OPENROUTER_MAX_TOKENS + temperature = 1.0 if "openai/gpt-5" in model_name else OPENROUTER_TEMPERATURE + + lm = dspy.LM( + model=model_name, + api_key=openrouter_key, + api_base=OPENROUTER_API_BASE, + max_tokens=max_tokens, + temperature=temperature + ) + return lm + except Exception as e: + raise Exception(f"Failed to create LM: {str(e)}") + +def initialize_dspy(model_name: str = DEFAULT_MODEL, use_cache: bool = DEFAULT_USE_CACHE) -> bool: + """Initialize DSPy with OpenRouter and selected model.""" + # Configure cache settings + try: + dspy.configure_cache( + enable_memory_cache=use_cache, + enable_disk_cache=use_cache + ) + except Exception: + # Cache configuration might fail in some environments, continue anyway + pass + + # Only configure DSPy once globally + if not hasattr(dspy, '_replit_configured'): + try: + # Get the LM for the default model + default_lm = get_dspy_lm(model_name) + dspy.configure(lm=default_lm) + dspy._replit_configured = True # type: ignore + except Exception as e: + raise Exception(f"{ERROR_DSPy_INIT}: {str(e)}") + + return True + +def format_tweet_for_display(tweet: str) -> str: + """Format tweet text for better display.""" + return tweet.strip() + +def calculate_tweet_length(tweet: str) -> int: + """Calculate tweet length.""" + return len(tweet.strip()) + +def is_valid_tweet(tweet: str) -> bool: + """Check if tweet is valid (not empty and within character limit).""" + cleaned_tweet = tweet.strip() + return bool(cleaned_tweet) and len(cleaned_tweet) <= TWEET_MAX_LENGTH + +def save_settings(settings: Dict[str, Any]) -> None: + """Save settings to JSON file.""" + try: + with open(SETTINGS_FILE, 'w') as f: + json.dump(settings, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_SETTINGS}: {str(e)}") + +def load_settings() -> Dict[str, Any]: + """Load settings from JSON file.""" + try: + if os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, 'r') as f: + settings = json.load(f) + return settings if isinstance(settings, dict) else get_default_settings() + else: + # Return default settings if file doesn't exist + default_settings = get_default_settings() + save_settings(default_settings) + return default_settings + except Exception as e: + print(f"{ERROR_LOAD_SETTINGS}: {str(e)}") + return get_default_settings() + +def get_default_settings() -> Dict[str, Any]: + """Get default settings.""" + return { + "selected_model": DEFAULT_MODEL, + "iterations": DEFAULT_ITERATIONS, + "patience": DEFAULT_PATIENCE, + "use_cache": DEFAULT_USE_CACHE + } + +def save_input_history(history: List[str]) -> None: + """Save input history to JSON file.""" + try: + with open(HISTORY_FILE, 'w') as f: + json.dump(history, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_HISTORY}: {str(e)}") + +def load_input_history() -> List[str]: + """Load input history from JSON file.""" + try: + if os.path.exists(HISTORY_FILE): + with open(HISTORY_FILE, 'r') as f: + history = json.load(f) + return history if isinstance(history, list) else [] + else: + return [] + except Exception as e: + print(f"{ERROR_LOAD_HISTORY}: {str(e)}") + return [] + +def add_to_input_history(history: List[str], new_input: str) -> List[str]: + """ + Add a new input to history, maintaining max size and avoiding duplicates. + + Args: + history: Current history list + new_input: New input text to add + + Returns: + Updated history list with new input at the beginning + """ + # Strip whitespace from input + new_input = new_input.strip() + + # Don't add empty strings + if not new_input: + return history + + # Remove duplicate if it exists + if new_input in history: + history.remove(new_input) + + # Add to beginning of list + updated_history = [new_input] + history + + # Trim to max size + if len(updated_history) > MAX_HISTORY_ITEMS: + updated_history = updated_history[:MAX_HISTORY_ITEMS] + + return updated_history diff --git a/auto_classes.json b/auto_classes.json new file mode 100644 index 0000000..1662aca --- /dev/null +++ b/auto_classes.json @@ -0,0 +1,4 @@ +{ + "AutoConfig": "agent.agent.TweetOptimizerConfig", + "AutoAgent": "agent.agent.TweetOptimizerAgent" +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..f3cc5e0 --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "lm": "openrouter/google/gemini-2.5-flash", + "eval_lm": "openrouter/openai/gpt-5", + "categories": [ + "Engagement potential - how likely users are to like, retweet, or reply", + "Clarity and readability - how easy the tweet is to understand", + "Emotional impact - how well the tweet evokes feelings or reactions", + "Relevance to target audience - how well it resonates with intended readers" + ], + "max_iterations": 10, + "patience": 5 +} \ No newline at end of file diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..1eb4de6 --- /dev/null +++ b/constants.py @@ -0,0 +1,75 @@ +from typing import Dict, List + +# tweet configuration +TWEET_MAX_LENGTH = 280 +TWEET_TRUNCATION_SUFFIX = "..." +TWEET_TRUNCATION_LENGTH = TWEET_MAX_LENGTH - len(TWEET_TRUNCATION_SUFFIX) + +# score configuration +MIN_SCORE = 1 +MAX_SCORE = 9 +DEFAULT_SCORE = 5 + +# file paths +CATEGORIES_FILE = "categories.json" +SETTINGS_FILE = "settings.json" +HISTORY_FILE = "input_history.json" + +# history configuration +MAX_HISTORY_ITEMS = 50 # maximum number of historical inputs to store + +# model configuration +DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5" + +AVAILABLE_MODELS: Dict[str, str] = { + "Claude Sonnet 4.5": "openrouter/anthropic/claude-sonnet-4.5", + "Opus 4.1": "openrouter/anthropic/claude-opus-4.1", + "Gemini 2.5 Flash": "openrouter/google/gemini-2.5-flash", + "Gemini 2.5 Flash Lite": "openrouter/google/gemini-2.5-flash-lite", + "Gemini 2.5 Pro": "openrouter/google/gemini-2.5-pro", + "GPT-5": "openrouter/openai/gpt-5" +} + +# openrouter API configuration +OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" +OPENROUTER_MAX_TOKENS = 4096 +OPENROUTER_TEMPERATURE = 0.7 + +# optimization defaults +DEFAULT_ITERATIONS = 10 +DEFAULT_PATIENCE = 5 +DEFAULT_USE_CACHE = True + +# default evaluation categories +DEFAULT_CATEGORIES: List[str] = [ + "Engagement potential - how likely users are to like, retweet, or reply", + "Clarity and readability - how easy the tweet is to understand", + "Emotional impact - how well the tweet evokes feelings or reactions", + "Relevance to target audience - how well it resonates with intended readers" +] + +# error messages +ERROR_PARSING = "Default evaluation due to parsing error" +ERROR_VALIDATION = "Default evaluation due to validation error" +ERROR_GENERATION = "Tweet generation failed" +ERROR_EVALUATION = "Tweet evaluation failed" +ERROR_DSPy_INIT = "DSPy initialization failed" +ERROR_NO_API_KEY = "OPENROUTER_API_KEY environment variable is required" +ERROR_SAVE_CATEGORIES = "Failed to save categories" +ERROR_LOAD_CATEGORIES = "Failed to load categories" +ERROR_SAVE_SETTINGS = "Failed to save settings" +ERROR_LOAD_SETTINGS = "Failed to load settings" +ERROR_SAVE_HISTORY = "Failed to save input history" +ERROR_LOAD_HISTORY = "Failed to load input history" + +# cache configuration +CACHE_ENABLE_MEMORY = True +CACHE_ENABLE_DISK = True + +# iteration display +ITERATION_SLEEP_TIME = 0.1 # seconds + +# truncation display +CATEGORY_DISPLAY_MAX_LENGTH = 30 +CATEGORY_DISPLAY_TRUNCATION = "..." +CATEGORY_IMPROVEMENT_MAX_LENGTH = 50 diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..5be03ed --- /dev/null +++ b/helpers.py @@ -0,0 +1,85 @@ +from typing import Optional, Dict, Any +from models import EvaluationResult +from constants import MAX_SCORE + + +def format_evaluation_for_generator(evaluation: Optional[EvaluationResult]) -> str: + """ + Format an evaluation result as text for the generator module. + + Args: + evaluation: The evaluation result to format + + Returns: + Formatted string with category-by-category reasoning and scores + """ + if not evaluation or not evaluation.evaluations: + return "" + + eval_lines = [] + for eval in evaluation.evaluations: + eval_lines.append(f"{eval.category} (Score: {eval.score}/{MAX_SCORE}): {eval.reasoning}") + + return "\n".join(eval_lines) + + +def build_settings_dict( + selected_model: str, + iterations: int, + patience: int, + use_cache: bool +) -> Dict[str, Any]: + """ + Build a settings dictionary for saving. + + Args: + selected_model: The selected model name + iterations: Number of optimization iterations + patience: Patience threshold for early stopping + use_cache: Whether to use DSPy cache + + Returns: + Dictionary containing all settings + """ + return { + "selected_model": selected_model, + "iterations": iterations, + "patience": patience, + "use_cache": use_cache + } + + +def truncate_tweet(tweet: str, max_length: int, suffix: str = "...") -> str: + """ + Truncate a tweet to the maximum length with a suffix. + + Args: + tweet: The tweet text to truncate + max_length: Maximum allowed length + suffix: Suffix to add when truncating (default: "...") + + Returns: + Truncated tweet text + """ + tweet = tweet.strip() + if len(tweet) <= max_length: + return tweet + + truncation_point = max_length - len(suffix) + return tweet[:truncation_point] + suffix + + +def truncate_category_display(category: str, max_length: int = 30) -> str: + """ + Truncate a category name for display purposes. + + Args: + category: The category name + max_length: Maximum display length (default: 30) + + Returns: + Truncated category name with "..." if needed + """ + if len(category) <= max_length: + return category + return category[:max_length] + "..." diff --git a/hill_climbing.py b/hill_climbing.py new file mode 100644 index 0000000..cef8aa3 --- /dev/null +++ b/hill_climbing.py @@ -0,0 +1,119 @@ +from typing import List, Iterator, Tuple, Dict +from models import EvaluationResult +from modules import TweetGeneratorModule, TweetEvaluatorModule +from helpers import format_evaluation_for_generator + +class HillClimbingOptimizer: + """Hill climbing optimizer for tweet improvement.""" + + def __init__( + self, + generator: TweetGeneratorModule, + evaluator: TweetEvaluatorModule, + categories: List[str], + max_iterations: int = 10, + patience: int = 5 + ): + self.generator = generator + self.evaluator = evaluator + self.categories = categories + self.max_iterations = max_iterations + self.patience = patience + + def optimize(self, initial_text: str) -> Iterator[Tuple[str, EvaluationResult, bool, int, Dict[str, str], Dict[str, str]]]: + """ + Optimize tweet using hill climbing algorithm. + + Yields: + Tuple of (current_tweet, evaluation_result, is_improvement, patience_counter, generator_inputs, evaluator_inputs) + """ + # Generate initial tweet + generator_inputs = { + "input_text": initial_text, + "current_tweet": "", + "previous_evaluation": "" + } + current_tweet = self.generator( + input_text=initial_text, + current_tweet="", + previous_evaluation=None + ) + + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": "", + "tweet_text": current_tweet + } + current_score = self.evaluator( + tweet_text=current_tweet, + categories=self.categories, + original_text=initial_text, + current_best_tweet="" + ) + + best_tweet = current_tweet + best_score = current_score + patience_counter = 0 + + yield (current_tweet, current_score, True, patience_counter, generator_inputs, evaluator_inputs) + + for iteration in range(1, self.max_iterations): + # Generate improved tweet with previous evaluation as feedback + try: + # Format evaluation for display in generator inputs + eval_text = format_evaluation_for_generator(best_score) + + generator_inputs = { + "input_text": initial_text, + "current_tweet": best_tweet, + "previous_evaluation": eval_text + } + + candidate_tweet = self.generator( + input_text=initial_text, + current_tweet=best_tweet, + previous_evaluation=best_score + ) + + # Evaluate candidate + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": best_tweet, + "tweet_text": candidate_tweet + } + candidate_score = self.evaluator( + tweet_text=candidate_tweet, + categories=self.categories, + original_text=initial_text, + current_best_tweet=best_tweet + ) + + # Check if candidate is better (hill climbing condition) + is_improvement = candidate_score > best_score + + if is_improvement: + best_tweet = candidate_tweet + best_score = candidate_score + patience_counter = 0 + yield (candidate_tweet, candidate_score, True, patience_counter, generator_inputs, evaluator_inputs) + else: + patience_counter += 1 + yield (best_tweet, candidate_score, False, patience_counter, generator_inputs, evaluator_inputs) + + # Early stopping if no improvement for 'patience' iterations + if patience_counter >= self.patience: + break + + except Exception as e: + # If generation fails, yield current best + patience_counter += 1 + evaluator_inputs = { + "original_text": initial_text, + "current_best_tweet": best_tweet, + "tweet_text": best_tweet + } + yield (best_tweet, best_score, False, patience_counter, generator_inputs, evaluator_inputs) + + if patience_counter >= self.patience: + break + diff --git a/main.py b/main.py new file mode 100644 index 0000000..f2aa568 --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +from agent.agent import TweetOptimizerAgent, TweetOptimizerConfig + +def main(): + # create agent with default config + config = TweetOptimizerConfig() + tweet_optimizer = TweetOptimizerAgent(config) + import os + + # set up test environment (replace with real API key for actual usage) + if not os.getenv("OPENROUTER_API_KEY"): + raise ValueError("OPENROUTER_API_KEY environment variable is not set") + + # full optimization process + print("\n=== Full Optimization Process ===") + try: + results = tweet_optimizer( + input_text="Anthropic added a new OSS model on HuggingFace.", + iterations=10, # Reduced for testing + patience=8 + ) + print(f"Initial text: {results['initial_text']}") + print(f"Final tweet: {results['final_tweet']}") + print(f"Best score: {results['best_score']:.2f}") + print(f"Iterations run: {results['iterations_run']}") + print(f"Improvements found: {results['improvement_count']}") + print(f"Early stopped: {results['early_stopped']}") + except Exception as e: + print(f"Error in optimization: {e}") + + # push to hub + print("\n=== Push to Hub ===") + try: + tweet_optimizer.push_to_hub( + "farouk1/tweet-optimizer-v2", + commit_message="Complete Migration", + with_code=True + ) + print("Successfully pushed to hub!") + except Exception as e: + print(f"Error pushing to hub: {e}") + + print("\n=== Agent Configuration ===") + print(f"Model: {config.lm}") + print(f"Categories: {config.categories}") + print(f"Max iterations: {config.max_iterations}") + print(f"Patience: {config.patience}") + +if __name__ == "__main__": + main() diff --git a/models.py b/models.py new file mode 100644 index 0000000..08dd15e --- /dev/null +++ b/models.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field, validator +from typing import List +from constants import MIN_SCORE, MAX_SCORE + +class CategoryEvaluation(BaseModel): + """Pydantic model for a single category evaluation with reasoning.""" + + category: str = Field(description="The evaluation category name") + reasoning: str = Field(description="Explanation for the score") + score: int = Field( + description=f"Score for this category ({MIN_SCORE}-{MAX_SCORE})", + ge=MIN_SCORE, + le=MAX_SCORE + ) + + @validator('score') + def validate_score(cls, score): + """Ensure score is within the valid range.""" + if not isinstance(score, int) or score < MIN_SCORE or score > MAX_SCORE: + raise ValueError(f"Score {score} must be an integer between {MIN_SCORE} and {MAX_SCORE}") + return score + +class EvaluationResult(BaseModel): + """Pydantic model for tweet evaluation results.""" + + evaluations: List[CategoryEvaluation] = Field( + description="List of category evaluations with reasoning and scores" + ) + + @validator('evaluations') + def validate_evaluations(cls, evals): + """Ensure we have at least one evaluation.""" + if not evals or len(evals) < 1: + raise ValueError("Must have at least one category evaluation") + return evals + + @property + def category_scores(self) -> List[int]: + """Get list of scores for backwards compatibility.""" + return [eval.score for eval in self.evaluations] + + def total_score(self) -> float: + """Calculate the total score across all categories.""" + return sum(eval.score for eval in self.evaluations) + + def average_score(self) -> float: + """Calculate the average score across all categories.""" + return self.total_score() / len(self.evaluations) + + def __gt__(self, other): + """Compare evaluation results based on total score.""" + if not isinstance(other, EvaluationResult): + return NotImplemented + return self.total_score() > other.total_score() + + def __eq__(self, other): + """Check equality based on total score.""" + if not isinstance(other, EvaluationResult): + return NotImplemented + return self.total_score() == other.total_score() diff --git a/modules.py b/modules.py new file mode 100644 index 0000000..f4d034e --- /dev/null +++ b/modules.py @@ -0,0 +1,128 @@ +import dspy +from typing import List, Optional +from models import EvaluationResult, CategoryEvaluation +from constants import ( + TWEET_MAX_LENGTH, + TWEET_TRUNCATION_SUFFIX, + DEFAULT_SCORE, + ERROR_PARSING, + ERROR_VALIDATION, + ERROR_GENERATION, + ERROR_EVALUATION, + MIN_SCORE, + MAX_SCORE +) +from helpers import format_evaluation_for_generator, truncate_tweet + +class TweetGenerator(dspy.Signature): + """Generate or improve a tweet based on input text and detailed evaluation feedback with reasoning.""" + + input_text: str = dspy.InputField(desc="Original text or current tweet to improve") + current_tweet: str = dspy.InputField(desc="Current best tweet version (empty for first generation)") + previous_evaluation: str = dspy.InputField(desc="Previous evaluation with category-by-category reasoning and scores (empty for first generation)") + improved_tweet: str = dspy.OutputField(desc=f"Generated or improved tweet text (max {TWEET_MAX_LENGTH} characters)") + +class TweetEvaluator(dspy.Signature): + """Evaluate a tweet across multiple custom categories. For each category, provide detailed reasoning explaining the score, then assign a score. Ensure the tweet maintains the same meaning as the original text.""" + + original_text: str = dspy.InputField(desc="Original input text that started the optimization") + current_best_tweet: str = dspy.InputField(desc="Current best tweet version for comparison (empty for first evaluation)") + tweet_text: str = dspy.InputField(desc="Tweet text to evaluate") + categories: str = dspy.InputField(desc="Comma-separated list of evaluation category descriptions") + evaluations: List[CategoryEvaluation] = dspy.OutputField( + desc=f"List of evaluations with category name, detailed reasoning, and score ({MIN_SCORE}-{MAX_SCORE}) for each category. Ensure the tweet conveys the same meaning as the original text." + ) + +class TweetGeneratorModule(dspy.Module): + """DSPy module for generating and improving tweets.""" + + def __init__(self): + super().__init__() + self.generate = dspy.ChainOfThought(TweetGenerator) + + def forward(self, input_text: str, current_tweet: str = "", previous_evaluation: Optional[EvaluationResult] = None) -> str: + """Generate or improve a tweet.""" + try: + # Format previous evaluation as text + eval_text = format_evaluation_for_generator(previous_evaluation) + + result = self.generate( + input_text=input_text, + current_tweet=current_tweet, + previous_evaluation=eval_text + ) + + # Ensure tweet doesn't exceed character limit + tweet = truncate_tweet(result.improved_tweet, TWEET_MAX_LENGTH, TWEET_TRUNCATION_SUFFIX) + + return tweet + except Exception as e: + raise Exception(f"{ERROR_GENERATION}: {str(e)}") + +class TweetEvaluatorModule(dspy.Module): + """DSPy module for evaluating tweets across custom categories.""" + + def __init__(self): + super().__init__() + self.evaluate = dspy.ChainOfThought(TweetEvaluator) + + def forward(self, tweet_text: str, categories: List[str], original_text: str = "", current_best_tweet: str = "") -> EvaluationResult: + """Evaluate a tweet across specified categories.""" + try: + # Join categories into comma-separated string + categories_str = ", ".join(categories) + + result = self.evaluate( + original_text=original_text, + current_best_tweet=current_best_tweet, + tweet_text=tweet_text, + categories=categories_str + ) + + # Extract and validate evaluations + evaluations = result.evaluations + + # Ensure we have the right number of evaluations + if len(evaluations) != len(categories): + # Create default evaluations if mismatch + evaluations = [ + CategoryEvaluation( + category=cat, + reasoning=ERROR_PARSING, + score=DEFAULT_SCORE + ) for cat in categories + ] + else: + # Validate each evaluation + validated_evals = [] + for i, eval in enumerate(evaluations): + try: + # Ensure score is valid + score = max(MIN_SCORE, min(MAX_SCORE, int(eval.score))) + validated_evals.append(CategoryEvaluation( + category=categories[i] if i < len(categories) else eval.category, + reasoning=eval.reasoning if eval.reasoning else "No reasoning provided", + score=score + )) + except (ValueError, TypeError, AttributeError): + validated_evals.append(CategoryEvaluation( + category=categories[i] if i < len(categories) else "Unknown", + reasoning=ERROR_VALIDATION, + score=DEFAULT_SCORE + )) + evaluations = validated_evals + + # Create validated result + validated_result = EvaluationResult(evaluations=evaluations) + + return validated_result + except Exception as e: + # Return default evaluations on error + default_evals = [ + CategoryEvaluation( + category=cat, + reasoning=f"{ERROR_EVALUATION}: {str(e)}", + score=DEFAULT_SCORE + ) for cat in categories + ] + return EvaluationResult(evaluations=default_evals) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e523742 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "tweet-optimizer-v2" +version = "0.1.0" +description = "CLI tool for optimizing tweets using DSPy and hill-climbing algorithm" +requires-python = ">=3.11" +dependencies = [ + "dspy>=3.0.3", + "dspy-ai>=3.0.3", + "modaic>=0.1.1", + "pandas>=2.3.3", + "pydantic>=2.12.2", + "pytest>=8.4.2", + "pytest-mock>=3.15.1", + "requests>=2.32.5", +] + +[project.scripts] +tweet-optimizer = "cli:main" diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..c99aea2 --- /dev/null +++ b/utils.py @@ -0,0 +1,192 @@ +import json +import os +import dspy +from typing import List, Dict, Any +from constants import ( + CATEGORIES_FILE, + SETTINGS_FILE, + HISTORY_FILE, + DEFAULT_CATEGORIES, + DEFAULT_MODEL, + DEFAULT_ITERATIONS, + DEFAULT_PATIENCE, + DEFAULT_USE_CACHE, + MAX_HISTORY_ITEMS, + OPENROUTER_API_BASE, + OPENROUTER_MAX_TOKENS, + OPENROUTER_TEMPERATURE, + ERROR_NO_API_KEY, + ERROR_SAVE_CATEGORIES, + ERROR_LOAD_CATEGORIES, + ERROR_SAVE_SETTINGS, + ERROR_LOAD_SETTINGS, + ERROR_SAVE_HISTORY, + ERROR_LOAD_HISTORY, + ERROR_DSPy_INIT, + TWEET_MAX_LENGTH +) + +def save_categories(categories: List[str]) -> None: + """Save categories to JSON file.""" + try: + with open(CATEGORIES_FILE, 'w') as f: + json.dump(categories, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_CATEGORIES}: {str(e)}") + +def load_categories() -> List[str]: + """Load categories from JSON file.""" + try: + if os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'r') as f: + categories = json.load(f) + return categories if isinstance(categories, list) else [] + else: + save_categories(DEFAULT_CATEGORIES) + return DEFAULT_CATEGORIES + except Exception as e: + print(f"{ERROR_LOAD_CATEGORIES}: {str(e)}") + return [] + +def get_dspy_lm(model_name: str): + """Get a DSPy LM instance for the specified model (cached per model).""" + try: + openrouter_key = os.getenv("OPENROUTER_API_KEY") + if not openrouter_key: + raise ValueError(ERROR_NO_API_KEY) + + max_tokens = 16000 if "openai/gpt-5" in model_name else OPENROUTER_MAX_TOKENS + temperature = 1.0 if "openai/gpt-5" in model_name else OPENROUTER_TEMPERATURE + + lm = dspy.LM( + model=model_name, + api_key=openrouter_key, + api_base=OPENROUTER_API_BASE, + max_tokens=max_tokens, + temperature=temperature + ) + return lm + except Exception as e: + raise Exception(f"Failed to create LM: {str(e)}") + +def initialize_dspy(model_name: str = DEFAULT_MODEL, use_cache: bool = DEFAULT_USE_CACHE) -> bool: + """Initialize DSPy with OpenRouter and selected model.""" + # Configure cache settings + try: + dspy.configure_cache( + enable_memory_cache=use_cache, + enable_disk_cache=use_cache + ) + except Exception: + # Cache configuration might fail in some environments, continue anyway + pass + + # Only configure DSPy once globally + if not hasattr(dspy, '_replit_configured'): + try: + # Get the LM for the default model + default_lm = get_dspy_lm(model_name) + dspy.configure(lm=default_lm) + dspy._replit_configured = True # type: ignore + except Exception as e: + raise Exception(f"{ERROR_DSPy_INIT}: {str(e)}") + + return True + +def format_tweet_for_display(tweet: str) -> str: + """Format tweet text for better display.""" + return tweet.strip() + +def calculate_tweet_length(tweet: str) -> int: + """Calculate tweet length.""" + return len(tweet.strip()) + +def is_valid_tweet(tweet: str) -> bool: + """Check if tweet is valid (not empty and within character limit).""" + cleaned_tweet = tweet.strip() + return bool(cleaned_tweet) and len(cleaned_tweet) <= TWEET_MAX_LENGTH + +def save_settings(settings: Dict[str, Any]) -> None: + """Save settings to JSON file.""" + try: + with open(SETTINGS_FILE, 'w') as f: + json.dump(settings, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_SETTINGS}: {str(e)}") + +def load_settings() -> Dict[str, Any]: + """Load settings from JSON file.""" + try: + if os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, 'r') as f: + settings = json.load(f) + return settings if isinstance(settings, dict) else get_default_settings() + else: + # Return default settings if file doesn't exist + default_settings = get_default_settings() + save_settings(default_settings) + return default_settings + except Exception as e: + print(f"{ERROR_LOAD_SETTINGS}: {str(e)}") + return get_default_settings() + +def get_default_settings() -> Dict[str, Any]: + """Get default settings.""" + return { + "selected_model": DEFAULT_MODEL, + "iterations": DEFAULT_ITERATIONS, + "patience": DEFAULT_PATIENCE, + "use_cache": DEFAULT_USE_CACHE + } + +def save_input_history(history: List[str]) -> None: + """Save input history to JSON file.""" + try: + with open(HISTORY_FILE, 'w') as f: + json.dump(history, f, indent=2) + except Exception as e: + print(f"{ERROR_SAVE_HISTORY}: {str(e)}") + +def load_input_history() -> List[str]: + """Load input history from JSON file.""" + try: + if os.path.exists(HISTORY_FILE): + with open(HISTORY_FILE, 'r') as f: + history = json.load(f) + return history if isinstance(history, list) else [] + else: + return [] + except Exception as e: + print(f"{ERROR_LOAD_HISTORY}: {str(e)}") + return [] + +def add_to_input_history(history: List[str], new_input: str) -> List[str]: + """ + Add a new input to history, maintaining max size and avoiding duplicates. + + Args: + history: Current history list + new_input: New input text to add + + Returns: + Updated history list with new input at the beginning + """ + # Strip whitespace from input + new_input = new_input.strip() + + # Don't add empty strings + if not new_input: + return history + + # Remove duplicate if it exists + if new_input in history: + history.remove(new_input) + + # Add to beginning of list + updated_history = [new_input] + history + + # Trim to max size + if len(updated_history) > MAX_HISTORY_ITEMS: + updated_history = updated_history[:MAX_HISTORY_ITEMS] + + return updated_history