"
+ "No valid input arguments found, please enter manually.")
+ directory_path = input("Enter directory path: ")
+ license_template_path = input("Enter license template path: ")
+ else:
+ directory_path = sys.argv[1]
+ license_template_path = sys.argv[2]
+
+ start_line_start_with = "# =========== Copyright"
+ end_line_start_with = "# =========== Copyright"
+ update_license_in_directory(
+ directory_path,
+ license_template_path,
+ start_line_start_with,
+ end_line_start_with,
+ )
diff --git a/backend/vendor/camel-oasis/README.md b/backend/vendor/camel-oasis/README.md
new file mode 100644
index 00000000..8987fbed
--- /dev/null
+++ b/backend/vendor/camel-oasis/README.md
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+
OASIS: Open Agent Social Interaction Simulations with One Million Agents
+
+
+[![Documentation][docs-image]][docs-url]
+[![Discord][discord-image]][discord-url]
+[![X][x-image]][x-url]
+[![Reddit][reddit-image]][reddit-url]
+[![Wechat][wechat-image]][wechat-url]
+[![Wechat][oasis-image]][oasis-url]
+[![Hugging Face][huggingface-image]][huggingface-url]
+[![Star][star-image]][star-url]
+[![Package License][package-license-image]][package-license-url]
+
+
+
+[Community](https://github.com/camel-ai/camel#community) |
+[Paper](https://arxiv.org/abs/2411.11581) |
+[Examples](https://github.com/camel-ai/oasis/tree/main/scripts) |
+[Dataset](https://huggingface.co/datasets/echo-yiyiyi/oasis-dataset) |
+[Citation](https://github.com/camel-ai/oasis#-citation) |
+[Contributing](https://github.com/camel-ai/oasis#-contributing-to-oasis) |
+[CAMEL-AI](https://www.camel-ai.org/)
+
+
+
+
+
+
+
+
+
+
+🏝️ OASIS is a scalable, open-source social media simulator that incorporates large language model agents to realistically mimic the behavior of up to one million users on platforms like Twitter and Reddit. It's designed to facilitate the study of complex social phenomena such as information spread, group polarization, and herd behavior, offering a versatile tool for exploring diverse social dynamics and user interactions in digital environments.
+
+
+
+
+
+
+🌟 Star OASIS on GitHub and be instantly notified of new releases.
+
+
+
+
+
+

+
+
+
+
+
+## ✨ Key Features
+
+### 📈 Scalability
+
+OASIS supports simulations of up to ***one million agents***, enabling studies of social media dynamics at a scale comparable to real-world platforms.
+
+### 📲 Dynamic Environments
+
+Adapts to real-time changes in social networks and content, mirroring the fluid dynamics of platforms like **Twitter** and **Reddit** for authentic simulation experiences.
+
+### 👍🏼 Diverse Action Spaces
+
+Agents can perform **23 actions**, such as following, commenting, and reposting, allowing for rich, multi-faceted interactions.
+
+### 🔥 Integrated Recommendation Systems
+
+Features **interest-based** and **hot-score-based recommendation algorithms**, simulating how users discover content and interact within social media platforms.
+
+
+
+## 📺 Demo Video
+
+### Introducing OASIS: Open Agent Social Interaction Simulations with One Million Agents
+
+https://github.com/user-attachments/assets/3bd2553c-d25d-4d8c-a739-1af51354b15a
+
+
+
+For more showcaes:
+
+- Can 1,000,000 AI agents simulate social media?
+ [→Watch demo](https://www.youtube.com/watch?v=lprGHqkApus&t=2s)
+
+
+
+## 🎯 Usecase
+
+
+
+## ⚙️ Quick Start
+
+1. **Install the OASIS package:**
+
+Installing OASIS is a breeze thanks to its availability on PyPI. Simply open your terminal and run:
+
+```bash
+pip install camel-oasis
+```
+
+2. **Set up your OpenAI API key:**
+
+```bash
+# For Bash shell (Linux, macOS, Git Bash on Windows):
+export OPENAI_API_KEY=
+
+# For Windows Command Prompt:
+set OPENAI_API_KEY=
+```
+
+3. **Prepare the agent profile file:**
+
+Create the profile you want to assign to the agent. As an example, you can download [user_data_36.json](https://github.com/camel-ai/oasis/blob/main/data/reddit/user_data_36.json) and place it in your local `./data/reddit` folder.
+
+4. **Run the following Python code:**
+
+```python
+import asyncio
+import os
+
+from camel.models import ModelFactory
+from camel.types import ModelPlatformType, ModelType
+
+import oasis
+from oasis import (ActionType, LLMAction, ManualAction,
+ generate_reddit_agent_graph)
+
+
+async def main():
+ # Define the model for the agents
+ openai_model = ModelFactory.create(
+ model_platform=ModelPlatformType.OPENAI,
+ model_type=ModelType.GPT_4O_MINI,
+ )
+
+ # Define the available actions for the agents
+ available_actions = [
+ ActionType.LIKE_POST,
+ ActionType.DISLIKE_POST,
+ ActionType.CREATE_POST,
+ ActionType.CREATE_COMMENT,
+ ActionType.LIKE_COMMENT,
+ ActionType.DISLIKE_COMMENT,
+ ActionType.SEARCH_POSTS,
+ ActionType.SEARCH_USER,
+ ActionType.TREND,
+ ActionType.REFRESH,
+ ActionType.DO_NOTHING,
+ ActionType.FOLLOW,
+ ActionType.MUTE,
+ ]
+
+ agent_graph = await generate_reddit_agent_graph(
+ profile_path="./data/reddit/user_data_36.json",
+ model=openai_model,
+ available_actions=available_actions,
+ )
+
+ # Define the path to the database
+ db_path = "./data/reddit_simulation.db"
+
+ # Delete the old database
+ if os.path.exists(db_path):
+ os.remove(db_path)
+
+ # Make the environment
+ env = oasis.make(
+ agent_graph=agent_graph,
+ platform=oasis.DefaultPlatformType.REDDIT,
+ database_path=db_path,
+ )
+
+ # Run the environment
+ await env.reset()
+
+ actions_1 = {}
+ actions_1[env.agent_graph.get_agent(0)] = [
+ ManualAction(action_type=ActionType.CREATE_POST,
+ action_args={"content": "Hello, world!"}),
+ ManualAction(action_type=ActionType.CREATE_COMMENT,
+ action_args={
+ "post_id": "1",
+ "content": "Welcome to the OASIS World!"
+ })
+ ]
+ actions_1[env.agent_graph.get_agent(1)] = ManualAction(
+ action_type=ActionType.CREATE_COMMENT,
+ action_args={
+ "post_id": "1",
+ "content": "I like the OASIS world."
+ })
+ await env.step(actions_1)
+
+ actions_2 = {
+ agent: LLMAction()
+ for _, agent in env.agent_graph.get_agents()
+ }
+
+ # Perform the actions
+ await env.step(actions_2)
+
+ # Close the environment
+ await env.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+
+
+> \[!TIP\]
+> For more detailed instructions and additional configuration options, check out the [documentation](https://docs.oasis.camel-ai.org/).
+
+### More Tutorials
+
+To discover how to create profiles for large-scale users, as well as how to visualize and analyze social simulation data once your experiment concludes, please refer to [More Tutorials](examples/experiment/user_generation_visualization.md) for detailed guidance.
+
+
+

+
+
+## 📢 News
+
+### Upcoming Features & Contributions
+
+> We welcome community contributions! Join us in building these exciting features.
+
+- [Support Multi Modal Platform](https://github.com/camel-ai/oasis/issues/47)
+
+
+
+### Latest Updates
+
+📢 Update the camel-ai version to 0.2.78 and update the dataset HuggingFace link. - 📆 December 4, 2025
+
+- Add the report post action to mark inappropriate content. - 📆 June 8, 2025
+- Add features for creating group chats, sending messages in group chats, and leaving group chats. - 📆 June 6, 2025
+- Support Interview Action for asking agents specific questions and getting answers. - 📆 June 2, 2025
+- Support customization of each agent's models, tools, and prompts; refactor the interface to follow the PettingZoo style. - 📆 May 22, 2025
+- Refactor into the OASIS environment, publish camel-oasis on PyPI, and release the documentation. - 📆 April 24, 2025
+- Support OPENAI Embedding model for Twhin-Bert Recommendation System. - 📆 March 25, 2025
+ ...
+- Slightly refactoring the database to add Quote Action and modify Repost Action - 📆 January 13, 2025
+- Added the demo video and oasis's star history in the README - 📆 January 5, 2025
+- Introduced an Electronic Mall on the Reddit platform - 📆 December 5, 2024
+- OASIS initially released on arXiv - 📆 November 19, 2024
+- OASIS GitHub repository initially launched - 📆 November 19, 2024
+
+## 🔎 Follow-up Research
+
+- [MultiAgent4Collusion](https://github.com/renqibing/MultiAgent4Collusion): multi-agent collusion simulation framework in social systems
+- More to come...
+
+If your research is based on OASIS, we'd be happy to feature your work here—feel free to reach out or submit a pull request to add it to the [README](https://github.com/camel-ai/oasis/blob/main/README.md)!
+
+## 🥂 Contributing to OASIS🏝️
+
+> We greatly appreciate your interest in contributing to our open-source initiative. To ensure a smooth collaboration and the success of contributions, we adhere to a set of contributing guidelines similar to those established by CAMEL. For a comprehensive understanding of the steps involved in contributing to our project, please refer to the OASIS [contributing guidelines](https://github.com/camel-ai/oasis/blob/master/CONTRIBUTING.md). 🤝🚀
+>
+> An essential part of contributing involves not only submitting new features with accompanying tests (and, ideally, examples) but also ensuring that these contributions pass our automated pytest suite. This approach helps us maintain the project's quality and reliability by verifying compatibility and functionality.
+
+## 📬 Community & Contact
+
+If you're keen on exploring new research opportunities or discoveries with our platform and wish to dive deeper or suggest new features, we're here to talk. Feel free to get in touch for more details at camel.ai.team@gmail.com.
+
+
+
+- Join us ([*Discord*](https://discord.camel-ai.org/) or [*WeChat*](https://ghli.org/camel/wechat.png)) in pushing the boundaries of finding the scaling laws of agents.
+
+- Join WechatGroup for further discussions!
+
+
+

+
+
+## 🌟 Star History
+
+[](https://star-history.com/#camel-ai/oasis&Date)
+
+## 🔗 Citation
+
+```
+@misc{yang2024oasisopenagentsocial,
+ title={OASIS: Open Agent Social Interaction Simulations with One Million Agents},
+ author={Ziyi Yang and Zaibin Zhang and Zirui Zheng and Yuxian Jiang and Ziyue Gan and Zhiyu Wang and Zijian Ling and Jinsong Chen and Martz Ma and Bowen Dong and Prateek Gupta and Shuyue Hu and Zhenfei Yin and Guohao Li and Xu Jia and Lijun Wang and Bernard Ghanem and Huchuan Lu and Chaochao Lu and Wanli Ouyang and Yu Qiao and Philip Torr and Jing Shao},
+ year={2024},
+ eprint={2411.11581},
+ archivePrefix={arXiv},
+ primaryClass={cs.CL},
+ url={https://arxiv.org/abs/2411.11581},
+}
+```
+
+## 🙌 Acknowledgment
+
+We would like to thank Douglas for designing the logo of our project.
+
+## 🖺 License
+
+The source code is licensed under Apache 2.0.
+
+[discord-image]: https://img.shields.io/discord/1082486657678311454?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb
+[discord-url]: https://discord.camel-ai.org/
+[docs-image]: https://img.shields.io/badge/Documentation-EB3ECC
+[docs-url]: https://docs.oasis.camel-ai.org/
+[huggingface-image]: https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-CAMEL--AI-ffc107?color=ffc107&logoColor=white
+[huggingface-url]: https://huggingface.co/camel-ai
+[oasis-image]: https://img.shields.io/badge/WeChat-OASISProject-brightgreen?logo=wechat&logoColor=white
+[oasis-url]: ./assets/wechatgroup.png
+[package-license-image]: https://img.shields.io/badge/License-Apache_2.0-blue.svg
+[package-license-url]: https://github.com/camel-ai/oasis/blob/main/licenses/LICENSE
+[reddit-image]: https://img.shields.io/reddit/subreddit-subscribers/CamelAI?style=plastic&logo=reddit&label=r%2FCAMEL&labelColor=white
+[reddit-url]: https://www.reddit.com/r/CamelAI/
+[star-image]: https://img.shields.io/github/stars/camel-ai/oasis?label=stars&logo=github&color=brightgreen
+[star-url]: https://github.com/camel-ai/oasis/stargazers
+[wechat-image]: https://img.shields.io/badge/WeChat-CamelAIOrg-brightgreen?logo=wechat&logoColor=white
+[wechat-url]: ./assets/wechat.JPGwechat.jpg
+[x-image]: https://img.shields.io/twitter/follow/CamelAIOrg?style=social
+[x-url]: https://x.com/CamelAIOrg
diff --git a/backend/vendor/camel-oasis/oasis/__init__.py b/backend/vendor/camel-oasis/oasis/__init__.py
new file mode 100644
index 00000000..ede59569
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/__init__.py
@@ -0,0 +1,31 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+__version__ = "0.2.5"
+
+from oasis.environment.env_action import LLMAction, ManualAction
+from oasis.environment.make import make
+from oasis.social_agent import (generate_reddit_agent_graph,
+ generate_twitter_agent_graph)
+from oasis.social_agent.agent import SocialAgent
+from oasis.social_agent.agent_graph import AgentGraph
+from oasis.social_platform.config import UserInfo
+from oasis.social_platform.platform import Platform
+from oasis.social_platform.typing import ActionType, DefaultPlatformType
+from oasis.testing.show_db import print_db_contents
+
+__all__ = [
+ "make", "Platform", "ActionType", "DefaultPlatformType", "ManualAction",
+ "LLMAction", "print_db_contents", "AgentGraph", "SocialAgent", "UserInfo",
+ "generate_reddit_agent_graph", "generate_twitter_agent_graph"
+]
diff --git a/backend/vendor/camel-oasis/oasis/clock/__init__.py b/backend/vendor/camel-oasis/oasis/clock/__init__.py
new file mode 100644
index 00000000..3446142f
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/clock/__init__.py
@@ -0,0 +1,16 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from .clock import Clock
+
+__all__ = ["Clock"]
diff --git a/backend/vendor/camel-oasis/oasis/clock/clock.py b/backend/vendor/camel-oasis/oasis/clock/clock.py
new file mode 100644
index 00000000..b0f8e197
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/clock/clock.py
@@ -0,0 +1,33 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from datetime import datetime
+
+
+class Clock:
+ r"""Clock used for the sandbox."""
+
+ def __init__(self, k: int = 1):
+ self.real_start_time = datetime.now()
+ self.k = k
+ self.time_step = 0
+
+ def time_transfer(self, now_time: datetime,
+ start_time: datetime) -> datetime:
+ time_diff = now_time - self.real_start_time
+ adjusted_diff = self.k * time_diff
+ adjusted_time = start_time + adjusted_diff
+ return adjusted_time
+
+ def get_time_step(self) -> str:
+ return str(self.time_step)
diff --git a/backend/vendor/camel-oasis/oasis/environment/__init__.py b/backend/vendor/camel-oasis/oasis/environment/__init__.py
new file mode 100644
index 00000000..d963f8cc
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/environment/__init__.py
@@ -0,0 +1,13 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
diff --git a/backend/vendor/camel-oasis/oasis/environment/env.py b/backend/vendor/camel-oasis/oasis/environment/env.py
new file mode 100644
index 00000000..4d815c60
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/environment/env.py
@@ -0,0 +1,208 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+import asyncio
+import logging
+import os
+from datetime import datetime
+from typing import List, Union
+
+from oasis.environment.env_action import LLMAction, ManualAction
+from oasis.social_agent.agent import SocialAgent
+from oasis.social_agent.agent_graph import AgentGraph
+from oasis.social_agent.agents_generator import generate_custom_agents
+from oasis.social_platform.channel import Channel
+from oasis.social_platform.platform import Platform
+from oasis.social_platform.typing import (ActionType, DefaultPlatformType,
+ RecsysType)
+
+# Create log directory if it doesn't exist
+log_dir = "./log"
+if not os.path.exists(log_dir):
+ os.makedirs(log_dir)
+
+# Configure logger
+env_log = logging.getLogger("oasis.env")
+env_log.setLevel("INFO")
+
+# Add file handler to save logs to file
+current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+file_handler = logging.FileHandler(f"{log_dir}/oasis-{current_time}.log",
+ encoding="utf-8")
+file_handler.setLevel("INFO")
+file_handler.setFormatter(
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
+env_log.addHandler(file_handler)
+
+
+class OasisEnv:
+
+ def __init__(
+ self,
+ agent_graph: AgentGraph,
+ platform: Union[DefaultPlatformType, Platform],
+ database_path: str = None,
+ semaphore: int = 128,
+ ) -> None:
+ r"""Init the oasis environment.
+
+ Args:
+ agent_graph: The AgentGraph to use in the simulation.
+ platform: The platform type to use. Including
+ `DefaultPlatformType.TWITTER` or `DefaultPlatformType.REDDIT`.
+ Or you can pass a custom `Platform` instance.
+ database_path: The path to create a sqlite3 database. The file
+ extension must be `.db` such as `twitter_simulation.db`.
+ """
+ # Initialize the agent graph
+ self.agent_graph = agent_graph
+ # Use a semaphore to limit the number of concurrent requests
+ self.llm_semaphore = asyncio.Semaphore(semaphore)
+ if isinstance(platform, DefaultPlatformType):
+ if database_path is None:
+ raise ValueError(
+ "database_path is required for DefaultPlatformType")
+ self.platform = platform
+ if platform == DefaultPlatformType.TWITTER:
+ self.channel = Channel()
+ self.platform = Platform(
+ db_path=database_path,
+ channel=self.channel,
+ recsys_type="twhin-bert",
+ refresh_rec_post_count=2,
+ max_rec_post_len=2,
+ following_post_count=3,
+ )
+ self.platform_type = DefaultPlatformType.TWITTER
+ elif platform == DefaultPlatformType.REDDIT:
+ self.channel = Channel()
+ self.platform = Platform(
+ db_path=database_path,
+ channel=self.channel,
+ recsys_type="reddit",
+ allow_self_rating=True,
+ show_score=True,
+ max_rec_post_len=100,
+ refresh_rec_post_count=5,
+ )
+ self.platform_type = DefaultPlatformType.REDDIT
+ else:
+ raise ValueError(f"Invalid platform: {platform}. Only "
+ "DefaultPlatformType.TWITTER or "
+ "DefaultPlatformType.REDDIT are supported.")
+ elif isinstance(platform, Platform):
+ if database_path != platform.db_path:
+ env_log.warning("database_path is not the same as the "
+ "platform.db_path, using the platform.db_path")
+ self.platform = platform
+ self.channel = platform.channel
+ if platform.recsys_type == RecsysType.REDDIT:
+ self.platform_type = DefaultPlatformType.REDDIT
+ else:
+ self.platform_type = DefaultPlatformType.TWITTER
+ else:
+ raise ValueError(
+ f"Invalid platform: {platform}. You should pass a "
+ "DefaultPlatformType or a Platform instance.")
+
+ async def reset(self) -> None:
+ r"""Start the platform and sign up the agents."""
+ self.platform_task = asyncio.create_task(self.platform.running())
+ self.agent_graph = await generate_custom_agents(
+ channel=self.channel, agent_graph=self.agent_graph)
+
+ async def _perform_llm_action(self, agent):
+ r"""Send the request to the llm model and execute the action.
+ """
+ async with self.llm_semaphore:
+ return await agent.perform_action_by_llm()
+
+ async def _perform_interview_action(self, agent, interview_prompt: str):
+ r"""Send the request to the llm model and execute the interview.
+ """
+ async with self.llm_semaphore:
+ return await agent.perform_interview(interview_prompt)
+
+ async def step(
+ self, actions: dict[SocialAgent, Union[ManualAction, LLMAction,
+ List[Union[ManualAction,
+ LLMAction]]]]
+ ) -> None:
+ r"""Update the recommendation system and perform the actions.
+
+ Args:
+ actions(dict[SocialAgent, Union[ManualAction, LLMAction,
+ List[Union[ManualAction, LLMAction]]]]): The actions to
+ perform, including the manual(pre-defined) actions and llm
+ actions.
+ Returns:
+ None
+ """
+ # Update the recommendation system
+ await self.platform.update_rec_table()
+ env_log.info("update rec table.")
+
+ # Create tasks for both manual and LLM actions
+ tasks = []
+ for agent, action in actions.items():
+ if isinstance(action, list):
+ for single_action in action:
+ if isinstance(single_action, ManualAction):
+ if single_action.action_type == ActionType.INTERVIEW:
+ # Use the agent's perform_interview method for
+ # interview actions
+ interview_prompt = single_action.action_args.get(
+ "prompt", "")
+ tasks.append(
+ self._perform_interview_action(
+ agent, interview_prompt))
+ else:
+ tasks.append(
+ agent.perform_action_by_data(
+ single_action.action_type,
+ **single_action.action_args))
+ elif isinstance(single_action, LLMAction):
+ tasks.append(self._perform_llm_action(agent))
+ else:
+ if isinstance(action, ManualAction):
+ if action.action_type == ActionType.INTERVIEW:
+ # Use the agent's perform_interview method for
+ # interview actions
+ interview_prompt = action.action_args.get("prompt", "")
+ tasks.append(
+ self._perform_interview_action(
+ agent, interview_prompt))
+ else:
+ tasks.append(
+ agent.perform_action_by_data(
+ action.action_type, **action.action_args))
+ elif isinstance(action, LLMAction):
+ tasks.append(self._perform_llm_action(agent))
+
+ # Execute all tasks concurrently
+ await asyncio.gather(*tasks)
+ env_log.info("performed all actions.")
+ # # Control some agents to perform actions
+ # Update the clock
+ if self.platform_type == DefaultPlatformType.TWITTER:
+ self.platform.sandbox_clock.time_step += 1
+
+ async def close(self) -> None:
+ r"""Stop the platform and close the environment.
+ """
+ await self.channel.write_to_receive_queue(
+ (None, None, ActionType.EXIT))
+ await self.platform_task
+ env_log.info("Simulation finished! Please check the results in the "
+ f"database: {self.platform.db_path}. Note that the trace "
+ "table stored all the actions of the agents.")
diff --git a/backend/vendor/camel-oasis/oasis/environment/env_action.py b/backend/vendor/camel-oasis/oasis/environment/env_action.py
new file mode 100644
index 00000000..d2e44d2c
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/environment/env_action.py
@@ -0,0 +1,45 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from dataclasses import dataclass
+from typing import Any, Dict
+
+from oasis.social_platform.typing import ActionType
+
+
+@dataclass
+class ManualAction:
+ r"""Some manual predefined social platform actions that need to be
+ executed by certain agents.
+
+ Args:
+ agent_id: The ID of the agent that will perform the action.
+ action: The action to perform.
+ args: The arguments to pass to the action. For details of each args in
+ each action, please refer to
+ `https://github.com/camel-ai/oasis/blob/main/oasis/social_agent/agent_action.py`.
+ """
+ action_type: ActionType
+ action_args: Dict[str, Any]
+
+ def init(self, action_type, action_args):
+ self.action_type = action_type
+ self.action_args = action_args
+
+
+@dataclass
+class LLMAction:
+ r"""Represents actions generated by a Language Learning Model (LLM)."""
+
+ def init(self):
+ pass
diff --git a/backend/vendor/camel-oasis/oasis/environment/make.py b/backend/vendor/camel-oasis/oasis/environment/make.py
new file mode 100644
index 00000000..5d790b87
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/environment/make.py
@@ -0,0 +1,19 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from oasis.environment.env import OasisEnv
+
+
+def make(*args, **kwargs):
+ obj = OasisEnv(*args, **kwargs)
+ return obj
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/__init__.py b/backend/vendor/camel-oasis/oasis/social_agent/__init__.py
new file mode 100644
index 00000000..003f03a2
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/__init__.py
@@ -0,0 +1,23 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from .agent import SocialAgent
+from .agent_graph import AgentGraph
+from .agents_generator import (generate_agents_100w,
+ generate_reddit_agent_graph,
+ generate_twitter_agent_graph)
+
+__all__ = [
+ "SocialAgent", "AgentGraph", "generate_agents_100w",
+ "generate_reddit_agent_graph", "generate_twitter_agent_graph"
+]
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/agent.py b/backend/vendor/camel-oasis/oasis/social_agent/agent.py
new file mode 100644
index 00000000..5747a5df
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/agent.py
@@ -0,0 +1,321 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+import inspect
+import logging
+import sys
+from datetime import datetime
+from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union
+
+from camel.agents import ChatAgent
+from camel.messages import BaseMessage
+from camel.models import BaseModelBackend, ModelManager
+from camel.prompts import TextPrompt
+from camel.toolkits import FunctionTool
+from camel.types import OpenAIBackendRole
+
+from oasis.social_agent.agent_action import SocialAction
+from oasis.social_agent.agent_environment import SocialEnvironment
+from oasis.social_platform import Channel
+from oasis.social_platform.config import UserInfo
+from oasis.social_platform.typing import ActionType
+
+if TYPE_CHECKING:
+ from oasis.social_agent import AgentGraph
+
+if "sphinx" not in sys.modules:
+ agent_log = logging.getLogger(name="social.agent")
+ agent_log.setLevel("DEBUG")
+
+ if not agent_log.handlers:
+ now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ file_handler = logging.FileHandler(
+ f"./log/social.agent-{str(now)}.log")
+ file_handler.setLevel("DEBUG")
+ file_handler.setFormatter(
+ logging.Formatter(
+ "%(levelname)s - %(asctime)s - %(name)s - %(message)s"))
+ agent_log.addHandler(file_handler)
+
+ALL_SOCIAL_ACTIONS = [action.value for action in ActionType]
+
+
+class SocialAgent(ChatAgent):
+ r"""Social Agent."""
+
+ def __init__(self,
+ agent_id: int,
+ user_info: UserInfo,
+ user_info_template: TextPrompt | None = None,
+ channel: Channel | None = None,
+ model: Optional[Union[BaseModelBackend,
+ List[BaseModelBackend],
+ ModelManager]] = None,
+ agent_graph: "AgentGraph" = None,
+ available_actions: list[ActionType] = None,
+ tools: Optional[List[Union[FunctionTool, Callable]]] = None,
+ max_iteration: int = 1,
+ interview_record: bool = False):
+ self.social_agent_id = agent_id
+ self.user_info = user_info
+ self.channel = channel or Channel()
+ self.env = SocialEnvironment(SocialAction(agent_id, self.channel))
+ if user_info_template is None:
+ system_message_content = self.user_info.to_system_message()
+ else:
+ system_message_content = self.user_info.to_custom_system_message(
+ user_info_template)
+ system_message = BaseMessage.make_assistant_message(
+ role_name="system",
+ content=system_message_content, # system prompt
+ )
+
+ if not available_actions:
+ agent_log.info("No available actions defined, using all actions.")
+ self.action_tools = self.env.action.get_openai_function_list()
+ else:
+ all_tools = self.env.action.get_openai_function_list()
+ all_possible_actions = [tool.func.__name__ for tool in all_tools]
+
+ for action in available_actions:
+ action_name = action.value if isinstance(
+ action, ActionType) else action
+ if action_name not in all_possible_actions:
+ agent_log.warning(
+ f"Action {action_name} is not supported. Supported "
+ f"actions are: {', '.join(all_possible_actions)}")
+ self.action_tools = [
+ tool for tool in all_tools if tool.func.__name__ in [
+ a.value if isinstance(a, ActionType) else a
+ for a in available_actions
+ ]
+ ]
+ all_tools = (tools or []) + (self.action_tools or [])
+ super().__init__(
+ system_message=system_message,
+ model=model,
+ scheduling_strategy='random_model',
+ tools=all_tools,
+ )
+ self.max_iteration = max_iteration
+ self.interview_record = interview_record
+ self.agent_graph = agent_graph
+ self.test_prompt = (
+ "\n"
+ "Helen is a successful writer who usually writes popular western "
+ "novels. Now, she has an idea for a new novel that could really "
+ "make a big impact. If it works out, it could greatly "
+ "improve her career. But if it fails, she will have spent "
+ "a lot of time and effort for nothing.\n"
+ "\n"
+ "What do you think Helen should do?")
+
+ async def perform_action_by_llm(self):
+ # Get posts:
+ env_prompt = await self.env.to_text_prompt()
+ user_msg = BaseMessage.make_user_message(
+ role_name="User",
+ content=(
+ f"Please perform social media actions after observing the "
+ f"platform environments. Notice that don't limit your "
+ f"actions for example to just like the posts. "
+ f"Here is your social media environment: {env_prompt}"))
+ try:
+ agent_log.info(
+ f"Agent {self.social_agent_id} observing environment: "
+ f"{env_prompt}")
+ response = await self.astep(user_msg)
+ for tool_call in response.info['tool_calls']:
+ action_name = tool_call.tool_name
+ args = tool_call.args
+ agent_log.info(f"Agent {self.social_agent_id} performed "
+ f"action: {action_name} with args: {args}")
+ if action_name not in ALL_SOCIAL_ACTIONS:
+ agent_log.info(
+ f"Agent {self.social_agent_id} get the result: "
+ f"{tool_call.result}")
+ # Abort graph action for if 100w Agent
+ # self.perform_agent_graph_action(action_name, args)
+
+ return response
+ except Exception as e:
+ agent_log.error(f"Agent {self.social_agent_id} error: {e}")
+ return e
+
+ async def perform_test(self):
+ """
+ doing group polarization test for all agents.
+ TODO: rewrite the function according to the ChatAgent.
+ TODO: unify the test and interview function.
+ """
+ # user conduct test to agent
+ _ = BaseMessage.make_user_message(role_name="User",
+ content=("You are a twitter user."))
+ # Test memory should not be writed to memory.
+ # self.memory.write_record(MemoryRecord(user_msg,
+ # OpenAIBackendRole.USER))
+
+ openai_messages, num_tokens = self.memory.get_context()
+
+ openai_messages = ([{
+ "role":
+ self.system_message.role_name,
+ "content":
+ self.system_message.content.split("# RESPONSE FORMAT")[0],
+ }] + openai_messages + [{
+ "role": "user",
+ "content": self.test_prompt
+ }])
+
+ agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}")
+ # NOTE: this is a temporary solution.
+ # Camel can not stop updating the agents' memory after stop and astep
+ # now.
+ response = await self._aget_model_response(
+ openai_messages=openai_messages, num_tokens=num_tokens)
+ content = response.output_messages[0].content
+ agent_log.info(
+ f"Agent {self.social_agent_id} receive response: {content}")
+ return {
+ "user_id": self.social_agent_id,
+ "prompt": openai_messages,
+ "content": content
+ }
+
+ async def perform_interview(self, interview_prompt: str):
+ """
+ Perform an interview with the agent.
+ """
+ # user conduct test to agent
+ user_msg = BaseMessage.make_user_message(
+ role_name="User", content=("You are a twitter user."))
+
+ if self.interview_record:
+ # Test memory should not be writed to memory.
+ self.update_memory(message=user_msg, role=OpenAIBackendRole.SYSTEM)
+
+ openai_messages, num_tokens = self.memory.get_context()
+
+ openai_messages = ([{
+ "role":
+ self.system_message.role_name,
+ "content":
+ self.system_message.content.split("# RESPONSE FORMAT")[0],
+ }] + openai_messages + [{
+ "role": "user",
+ "content": interview_prompt
+ }])
+
+ agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}")
+ # NOTE: this is a temporary solution.
+ # Camel can not stop updating the agents' memory after stop and astep
+ # now.
+
+ response = await self._aget_model_response(
+ openai_messages=openai_messages, num_tokens=num_tokens)
+
+ content = response.output_messages[0].content
+
+ if self.interview_record:
+ # Test memory should not be writed to memory.
+ self.update_memory(message=response.output_messages[0],
+ role=OpenAIBackendRole.USER)
+ agent_log.info(
+ f"Agent {self.social_agent_id} receive response: {content}")
+
+ # Record the complete interview (prompt + response) through the channel
+ interview_data = {"prompt": interview_prompt, "response": content}
+ result = await self.env.action.perform_action(
+ interview_data, ActionType.INTERVIEW.value)
+
+ # Return the combined result
+ return {
+ "user_id": self.social_agent_id,
+ "prompt": openai_messages,
+ "content": content,
+ "success": result.get("success", False)
+ }
+
+ async def perform_action_by_hci(self) -> Any:
+ print("Please choose one function to perform:")
+ function_list = self.env.action.get_openai_function_list()
+ for i in range(len(function_list)):
+ agent_log.info(f"Agent {self.social_agent_id} function: "
+ f"{function_list[i].func.__name__}")
+
+ selection = int(input("Enter your choice: "))
+ if not 0 <= selection < len(function_list):
+ agent_log.error(f"Agent {self.social_agent_id} invalid input.")
+ return
+ func = function_list[selection].func
+
+ params = inspect.signature(func).parameters
+ args = []
+ for param in params.values():
+ while True:
+ try:
+ value = input(f"Enter value for {param.name}: ")
+ args.append(value)
+ break
+ except ValueError:
+ agent_log.error("Invalid input, please enter an integer.")
+
+ result = await func(*args)
+ return result
+
+ async def perform_action_by_data(self, func_name, *args, **kwargs) -> Any:
+ func_name = func_name.value if isinstance(func_name,
+ ActionType) else func_name
+ function_list = self.env.action.get_openai_function_list()
+ for i in range(len(function_list)):
+ if function_list[i].func.__name__ == func_name:
+ func = function_list[i].func
+ result = await func(*args, **kwargs)
+ self.update_memory(message=BaseMessage.make_user_message(
+ role_name=OpenAIBackendRole.SYSTEM,
+ content=f"Agent {self.social_agent_id} performed "
+ f"{func_name} with args: {args} and kwargs: {kwargs}"
+ f"and the result is {result}"),
+ role=OpenAIBackendRole.SYSTEM)
+ agent_log.info(f"Agent {self.social_agent_id}: {result}")
+ return result
+ raise ValueError(f"Function {func_name} not found in the list.")
+
+ def perform_agent_graph_action(
+ self,
+ action_name: str,
+ arguments: dict[str, Any],
+ ):
+ r"""Remove edge if action is unfollow or add edge
+ if action is follow to the agent graph.
+ """
+ if "unfollow" in action_name:
+ followee_id: int | None = arguments.get("followee_id", None)
+ if followee_id is None:
+ return
+ self.agent_graph.remove_edge(self.social_agent_id, followee_id)
+ agent_log.info(
+ f"Agent {self.social_agent_id} unfollowed Agent {followee_id}")
+ elif "follow" in action_name:
+ followee_id: int | None = arguments.get("followee_id", None)
+ if followee_id is None:
+ return
+ self.agent_graph.add_edge(self.social_agent_id, followee_id)
+ agent_log.info(
+ f"Agent {self.social_agent_id} followed Agent {followee_id}")
+
+ def __str__(self) -> str:
+ return (f"{self.__class__.__name__}(agent_id={self.social_agent_id}, "
+ f"model_type={self.model_type.value})")
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/agent_action.py b/backend/vendor/camel-oasis/oasis/social_agent/agent_action.py
new file mode 100644
index 00000000..078cdbb4
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/agent_action.py
@@ -0,0 +1,758 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from typing import Any
+
+from camel.toolkits import FunctionTool
+
+from oasis.social_platform.channel import Channel
+from oasis.social_platform.typing import ActionType
+
+
+class SocialAction:
+
+ def __init__(self, agent_id: int, channel: Channel):
+ self.agent_id = agent_id
+ self.channel = channel
+
+ def get_openai_function_list(self) -> list[FunctionTool]:
+ return [
+ FunctionTool(func) for func in [
+ self.create_post,
+ self.like_post,
+ self.repost,
+ self.quote_post,
+ self.unlike_post,
+ self.dislike_post,
+ self.undo_dislike_post,
+ self.search_posts,
+ self.search_user,
+ self.trend,
+ self.refresh,
+ self.do_nothing,
+ self.create_comment,
+ self.like_comment,
+ self.dislike_comment,
+ self.unlike_comment,
+ self.undo_dislike_comment,
+ self.follow,
+ self.unfollow,
+ self.mute,
+ self.unmute,
+ self.purchase_product,
+ self.interview,
+ self.report_post,
+ self.join_group,
+ self.leave_group,
+ self.send_to_group,
+ self.create_group,
+ self.listen_from_group,
+ ]
+ ]
+
+ async def perform_action(self, message: Any, type: str):
+ message_id = await self.channel.write_to_receive_queue(
+ (self.agent_id, message, type))
+ response = await self.channel.read_from_send_queue(message_id)
+ return response[2]
+
+ async def sign_up(self, user_name: str, name: str, bio: str):
+ r"""Signs up a new user with the provided username, name, and bio.
+
+ This method prepares a user message comprising the user's details and
+ invokes an asynchronous action to perform the sign-up process. On
+ successful execution, it returns a dictionary indicating success along
+ with the newly created user ID.
+
+ Args:
+ user_name (str): The username for the new user.
+ name (str): The full name of the new user.
+ bio (str): A brief biography of the new user.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the sign-up was
+ successful, and 'user_id' key maps to the integer ID of the
+ newly created user on success.
+
+ Example of a successful return:
+ {'success': True, 'user_id': 2}
+ """
+
+ # print(f"Agent {self.agent_id} is signing up with "
+ # f"user_name: {user_name}, name: {name}, bio: {bio}")
+ user_message = (user_name, name, bio)
+ return await self.perform_action(user_message, ActionType.SIGNUP.value)
+
+ async def refresh(self):
+ r"""Refresh to get recommended posts.
+
+ This method invokes an asynchronous action to refresh and fetch
+ recommended posts. On successful execution, it returns a dictionary
+ indicating success along with a list of posts. Each post in the list
+ contains details such as post ID, user ID, content, creation date,
+ and the number of likes.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the refresh is
+ successful. The 'posts' key maps to a list of dictionaries,
+ each representing a post with its details.
+
+ Example of a successful return:
+ {
+ "success": True,
+ "posts": [
+ {
+ "post_id": 1,
+ "user_id": 23,
+ "content": "This is an example post content.",
+ "created_at": "2024-05-14T12:00:00Z",
+ "num_likes": 5
+ },
+ {
+ "post_id": 2,
+ "user_id": 42,
+ "content": "Another example post content.",
+ "created_at": "2024-05-14T12:05:00Z",
+ "num_likes": 15
+ }
+ ]
+ }
+ """
+ return await self.perform_action(None, ActionType.REFRESH.value)
+
+ async def do_nothing(self):
+ """Perform no action.
+ Returns:
+ dict: A dictionary with 'success' indicating if the removal was
+ successful.
+ Example of a successful return:
+ {"success": True}
+ """
+ return await self.perform_action(None, ActionType.DO_NOTHING.value)
+
+ async def create_post(self, content: str):
+ r"""Create a new post with the given content.
+
+ This method invokes an asynchronous action to create a new post based
+ on the provided content. Upon successful execution, it returns a
+ dictionary indicating success and the ID of the newly created post.
+
+ Args:
+ content (str): The content of the post to be created.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the post creation was
+ successful. The 'post_id' key maps to the integer ID of the
+ newly created post.
+
+ Example of a successful return:
+ {'success': True, 'post_id': 50}
+ """
+ return await self.perform_action(content, ActionType.CREATE_POST.value)
+
+ async def repost(self, post_id: int):
+ r"""Repost a specified post.
+
+ This method invokes an asynchronous action to Repost a specified
+ post. It is identified by the given post ID. Upon successful
+ execution, it returns a dictionary indicating success and the ID of
+ the newly created repost.
+
+ Args:
+ post_id (int): The ID of the post to be reposted.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the Repost creation was
+ successful. The 'post_id' key maps to the integer ID of the
+ newly created repost.
+
+ Example of a successful return:
+ {"success": True, "post_id": 123}
+
+ Note:
+ Attempting to repost a post that the user has already reposted
+ will result in a failure.
+ """
+ return await self.perform_action(post_id, ActionType.REPOST.value)
+
+ async def quote_post(self, post_id: int, quote_content: str):
+ r"""Quote a specified post with a given quote content.
+
+ This method invokes an asynchronous action to quote a specified post
+ with a given quote content. Upon successful execution, it returns a
+ dictionary indicating success and the ID of the newly created quote.
+
+ Args:
+ post_id (int): The ID of the post to be quoted.
+ quote_content (str): The content of the quote to be created.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the quote creation was
+ successful. The 'post_id' key maps to the integer ID of the
+ newly created quote.
+
+ Example of a successful return:
+ {"success": True, "post_id": 123}
+
+ Note:
+ Attempting to quote a post that the user has already quoted will
+ result in a failure.
+ """
+ quote_message = (post_id, quote_content)
+ return await self.perform_action(quote_message, ActionType.QUOTE_POST)
+
+ async def like_post(self, post_id: int):
+ r"""Create a new like for a specified post.
+
+ This method invokes an asynchronous action to create a new like for a
+ post. It is identified by the given post ID. Upon successful
+ execution, it returns a dictionary indicating success and the ID of
+ the newly created like.
+
+ Args:
+ post_id (int): The ID of the post to be liked.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the like creation was
+ successful. The 'like_id' key maps to the integer ID of the
+ newly created like.
+
+ Example of a successful return:
+ {"success": True, "like_id": 123}
+
+ Note:
+ Attempting to like a post that the user has already liked will
+ result in a failure.
+ """
+ return await self.perform_action(post_id, ActionType.LIKE_POST.value)
+
+ async def unlike_post(self, post_id: int):
+ """Remove a like for a post.
+
+ This method removes a like from the database, identified by the
+ post's ID. It returns a dictionary indicating the success of the
+ operation and the ID of the removed like.
+
+ Args:
+ post_id (int): The ID of the post to be unliked.
+
+ Returns:
+ dict: A dictionary with 'success' indicating if the removal was
+ successful, and 'like_id' the ID of the removed like.
+
+ Example of a successful return:
+ {"success": True, "like_id": 123}
+
+ Note:
+ Attempting to remove a like for a post that the user has not
+ previously liked will result in a failure.
+ """
+ return await self.perform_action(post_id, ActionType.UNLIKE_POST.value)
+
+ async def dislike_post(self, post_id: int):
+ r"""Create a new dislike for a specified post.
+
+ This method invokes an asynchronous action to create a new dislike for
+ a post. It is identified by the given post ID. Upon successful
+ execution, it returns a dictionary indicating success and the ID of
+ the newly created dislike.
+
+ Args:
+ post_id (int): The ID of the post to be disliked.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the dislike creation was
+ successful. The 'dislike_id' key maps to the integer ID of the
+ newly created like.
+
+ Example of a successful return:
+ {"success": True, "dislike_id": 123}
+
+ Note:
+ Attempting to dislike a post that the user has already liked will
+ result in a failure.
+ """
+ return await self.perform_action(post_id,
+ ActionType.DISLIKE_POST.value)
+
+ async def undo_dislike_post(self, post_id: int):
+ """Remove a dislike for a post.
+
+ This method removes a dislike from the database, identified by the
+ post's ID. It returns a dictionary indicating the success of the
+ operation and the ID of the removed dislike.
+
+ Args:
+ post_id (int): The ID of the post to be unliked.
+
+ Returns:
+ dict: A dictionary with 'success' indicating if the removal was
+ successful, and 'dislike_id' the ID of the removed like.
+
+ Example of a successful return:
+ {"success": True, "dislike_id": 123}
+
+ Note:
+ Attempting to remove a dislike for a post that the user has not
+ previously liked will result in a failure.
+ """
+ return await self.perform_action(post_id,
+ ActionType.UNDO_DISLIKE_POST.value)
+
+ async def search_posts(self, query: str):
+ r"""Search posts based on a given query.
+
+ This method performs a search operation in the database for posts
+ that match the given query string. The search considers the
+ post's content, post ID, and user ID. It returns a dictionary
+ indicating the operation's success and, if successful, a list of
+ posts that match the query.
+
+ Args:
+ query (str): The search query string. The search is performed
+ against the post's content, post ID, and user ID.
+
+ Returns:
+ dict: A dictionary with a 'success' key indicating the operation's
+ success. On success, it includes a 'posts' key with a list of
+ dictionaries, each representing a post. On failure, it
+ includes an 'error' message or a 'message' indicating no
+ posts were found.
+
+ Example of a successful return:
+ {
+ "success": True,
+ "posts": [
+ {
+ "post_id": 1,
+ "user_id": 42,
+ "content": "Hello, world!",
+ "created_at": "2024-05-14T12:00:00Z",
+ "num_likes": 150
+ },
+ ...
+ ]
+ }
+ """
+ return await self.perform_action(query, ActionType.SEARCH_POSTS.value)
+
+ async def search_user(self, query: str):
+ r"""Search users based on a given query.
+
+ This asynchronous method performs a search operation in the database
+ for users that match the given query string. The search considers the
+ user's username, name, bio, and user ID. It returns a dictionary
+ indicating the operation's success and, if successful, a list of users
+ that match the query.
+
+ Args:
+ query (str): The search query string. The search is performed
+ against the user's username, name, bio, and user ID.
+
+ Returns:
+ dict: A dictionary with a 'success' key indicating the operation's
+ success. On success, it includes a 'users' key with a list of
+ dictionaries, each representing a user. On failure, it includes
+ an 'error' message or a 'message' indicating no users were
+ found.
+
+ Example of a successful return:
+ {
+ "success": True,
+ "users": [
+ {
+ "user_id": 1,
+ "user_name": "exampleUser",
+ "name": "John Doe",
+ "bio": "This is an example bio",
+ "created_at": "2024-05-14T12:00:00Z",
+ "num_followings": 100,
+ "num_followers": 150
+ },
+ ...
+ ]
+ }
+ """
+ return await self.perform_action(query, ActionType.SEARCH_USER.value)
+
+ async def follow(self, followee_id: int):
+ r"""Follow a user.
+
+ This method allows agent to follow another user (followee).
+ It checks if the agent initiating the follow request has a
+ corresponding user ID and if the follow relationship already exists.
+
+ Args:
+ followee_id (int): The user ID of the user to be followed.
+
+ Returns:
+ dict: A dictionary with a 'success' key indicating the operation's
+ success. On success, it includes a 'follow_id' key with the ID
+ of the newly created follow record. On failure, it includes an
+ 'error' message.
+
+ Example of a successful return:
+ {"success": True, "follow_id": 123}
+ """
+ return await self.perform_action(followee_id, ActionType.FOLLOW.value)
+
+ async def unfollow(self, followee_id: int):
+ r"""Unfollow a user.
+
+ This method allows agent to unfollow another user (followee). It
+ checks if the agent initiating the unfollow request has a
+ corresponding user ID and if the follow relationship exists. If so,
+ it removes the follow record from the database, updates the followers
+ and followings count for both users, and logs the action.
+
+ Args:
+ followee_id (int): The user ID of the user to be unfollowed.
+
+ Returns:
+ dict: A dictionary with a 'success' key indicating the operation's
+ success. On success, it includes a 'follow_id' key with the ID
+ of the removed follow record. On failure, it includes an
+ 'error' message.
+
+ Example of a successful return:
+ {"success": True, "follow_id": 123}
+ """
+ return await self.perform_action(followee_id,
+ ActionType.UNFOLLOW.value)
+
+ async def mute(self, mutee_id: int):
+ r"""Mute a user.
+
+ Allows agent to mute another user. Checks for an existing mute
+ record before adding a new one to the database.
+
+ Args:
+ mutee_id (int): ID of the user to be muted.
+
+ Returns:
+ dict: On success, returns a dictionary with 'success': True and
+ mute_id' of the new record. On failure, returns 'success':
+ False and an 'error' message.
+
+ Example of a successful return:
+ {"success": True, "mutee_id": 123}
+ """
+ return await self.perform_action(mutee_id, ActionType.MUTE.value)
+
+ async def unmute(self, mutee_id: int):
+ r"""Unmute a user.
+
+ Allows agent to remove a mute on another user. Checks for an
+ existing mute record before removing it from the database.
+
+ Args:
+ mutee_id (int): ID of the user to be unmuted.
+
+ Returns:
+ dict: On success, returns a dictionary with 'success': True and
+ 'mutee_id' of the unmuted record. On failure, returns
+ 'success': False and an 'error' message.
+
+ Example of a successful return:
+ {"success": True, "mutee_id": 123}
+ """
+ return await self.perform_action(mutee_id, ActionType.UNMUTE.value)
+
+ async def trend(self):
+ r"""Fetch the trending posts within a predefined time period.
+
+ Retrieves the top K posts with the most likes in the last specified
+ number of days.
+
+ Returns:
+ dict: On success, returns a dictionary with 'success': True and a
+ list of 'posts', each post being a dictionary containing
+ 'post_id', 'user_id', 'content', 'created_at', and
+ 'num_likes'. On failure, returns 'success': False and an
+ 'error' message or a message indicating no trending posts
+ were found.
+
+ Example of a successful return:
+ {
+ "success": True,
+ "posts": [
+ {
+ "post_id": 123,
+ "user_id": 456,
+ "content": "Example post content",
+ "created_at": "2024-05-14T12:00:00",
+ "num_likes": 789
+ },
+ ...
+ ]
+ }
+ """
+ return await self.perform_action(None, ActionType.TREND.value)
+
+ async def create_comment(self, post_id: int, content: str):
+ r"""Create a new comment for a specified post given content.
+
+ This method creates a new comment based on the provided content and
+ associates it with the given post ID. Upon successful execution, it
+ returns a dictionary indicating success and the ID of the newly created
+ comment.
+
+ Args:
+ post_id (int): The ID of the post to which the comment is to be
+ added.
+ content (str): The content of the comment to be created.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the comment creation was
+ successful. The 'comment_id' key maps to the integer ID of the
+ newly created comment.
+
+ Example of a successful return:
+ {'success': True, 'comment_id': 123}
+ """
+ comment_message = (post_id, content)
+ return await self.perform_action(comment_message,
+ ActionType.CREATE_COMMENT.value)
+
+ async def like_comment(self, comment_id: int):
+ r"""Create a new like for a specified comment.
+
+ This method invokes an action to create a new like for a comment,
+ identified by the given comment ID. Upon successful execution, it
+ returns a dictionary indicating success and the ID of the newly
+ created like.
+
+ Args:
+ comment_id (int): The ID of the comment to be liked.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the like creation was
+ successful. The 'like_id' key maps to the integer ID of the
+ newly created like.
+
+ Example of a successful return:
+ {"success": True, "comment_like_id": 456}
+
+ Note:
+ Attempting to like a comment that the user has already liked will
+ result in a failure.
+ """
+ return await self.perform_action(comment_id,
+ ActionType.LIKE_COMMENT.value)
+
+ async def unlike_comment(self, comment_id: int):
+ """Remove a like for a comment based on the comment's ID.
+
+ This method removes a like from the database, identified by the
+ comment's ID. It returns a dictionary indicating the success of the
+ operation and the ID of the removed like.
+
+ Args:
+ comment_id (int): The ID of the comment to be unliked.
+
+ Returns:
+ dict: A dictionary with 'success' indicating if the removal was
+ successful, and 'like_id' the ID of the removed like.
+
+ Example of a successful return:
+ {"success": True, "like_id": 456}
+
+ Note:
+ Attempting to remove a like for a comment that the user has not
+ previously liked will result in a failure.
+ """
+ return await self.perform_action(comment_id,
+ ActionType.UNLIKE_COMMENT.value)
+
+ async def dislike_comment(self, comment_id: int):
+ r"""Create a new dislike for a specified comment.
+
+ This method invokes an action to create a new dislike for a
+ comment, identified by the given comment ID. Upon successful execution,
+ it returns a dictionary indicating success and the ID of the newly
+ created dislike.
+
+ Args:
+ comment_id (int): The ID of the comment to be disliked.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the dislike creation was
+ successful. The 'dislike_id' key maps to the integer ID of the
+ newly created dislike.
+
+ Example of a successful return:
+ {"success": True, "comment_dislike_id": 456}
+
+ Note:
+ Attempting to dislike a comment that the user has already liked
+ will result in a failure.
+ """
+ return await self.perform_action(comment_id,
+ ActionType.DISLIKE_COMMENT.value)
+
+ async def undo_dislike_comment(self, comment_id: int):
+ """Remove a dislike for a comment.
+
+ This method removes a dislike from the database, identified by the
+ comment's ID. It returns a dictionary indicating the success of the
+ operation and the ID of the removed dislike.
+
+ Args:
+ comment_id (int): The ID of the comment to have the dislike
+ removed.
+
+ Returns:
+ dict: A dictionary with 'success' indicating if the removal was
+ successful, and 'dislike_id' the ID of the removed dislike.
+
+ Example of a successful return:
+ {"success": True, "dislike_id": 456}
+
+ Note:
+ Attempting to remove a dislike for a comment that the user has not
+ previously disliked will result in a failure.
+ """
+ return await self.perform_action(comment_id,
+ ActionType.UNDO_DISLIKE_COMMENT.value)
+
+ async def purchase_product(self, product_name: str, purchase_num: int):
+ r"""Purchase a product.
+
+ Args:
+ product_name (str): The name of the product to be purchased.
+ purchase_num (int): The number of products to be purchased.
+
+ Returns:
+ dict: A dictionary with 'success' indicating if the purchase was
+ successful.
+ """
+ purchase_message = (product_name, purchase_num)
+ return await self.perform_action(purchase_message,
+ ActionType.PURCHASE_PRODUCT.value)
+
+ async def interview(self, prompt: str):
+ r"""Interview an agent with the given prompt.
+
+ This method invokes an asynchronous action to interview an agent with a
+ specific prompt question. Upon successful execution,
+ it returns a dictionary containing a success status
+ and an interview_id for tracking.
+
+ Args:
+ prompt (str): The interview question or prompt to ask the agent.
+
+ Returns:
+ dict: A dictionary containing success status and an interview_id.
+
+ Example of a successful return:
+ {
+ "success": True,
+ "interview_id": "1621234567_0" # Timestamp_UserID format
+ }
+ """
+ return await self.perform_action(prompt, ActionType.INTERVIEW.value)
+
+ async def report_post(self, post_id: int, report_reason: str):
+ r"""Report a specified post with a given reason.
+
+ This method invokes an asynchronous action to report a specified post
+ with a given reason. Upon successful execution, it returns a
+ dictionary indicating success and the ID of the newly created report.
+
+ Args:
+ post_id (int): The ID of the post to be reported.
+ report_reason (str): The reason for reporting the post.
+
+ Returns:
+ dict: A dictionary with two key-value pairs. The 'success' key
+ maps to a boolean indicating whether the report creation was
+ successful. The 'report_id' key maps to the integer ID of the
+ newly created report.
+
+ Example of a successful return:
+ {"success": True, "report_id": 123}
+
+ Note:
+ Attempting to report a post that the user has already reported will
+ result in a failure.
+ """
+ report_message = (post_id, report_reason)
+ return await self.perform_action(report_message,
+ ActionType.REPORT_POST.value)
+
+ async def create_group(self, group_name: str):
+ r"""Creates a new group on the platform.
+
+ Args:
+ group_name (str): The name of the group to be created.
+
+ Returns:
+ dict: Platform response indicating success or failure,
+ e.g.{"success": True, "group_id": 1}
+ """
+ return await self.perform_action(group_name,
+ ActionType.CREATE_GROUP.value)
+
+ async def join_group(self, group_id: int):
+ r"""Joins a group with the specified ID.
+
+ Args:
+ group_id (int): The ID of the group to join.
+
+ Returns:
+ dict: Platform response indicating success or failure,
+ e.g. {"success": True}
+ """
+ return await self.perform_action(group_id, ActionType.JOIN_GROUP.value)
+
+ async def leave_group(self, group_id: int):
+ r"""Leaves a group with the specified ID.
+
+ Args:
+ group_id (int): The ID of the group to leave.
+
+ Returns:
+ dict: Platform response indicating success or failure, e.g.
+ {"success": True}
+ """
+ return await self.perform_action(group_id,
+ ActionType.LEAVE_GROUP.value)
+
+ async def send_to_group(self, group_id: int, message: str):
+ r"""Sends a message to a specific group.
+
+ Args:
+ group_id (int): The ID of the target group.
+ message (str): The content of the message to send.
+
+ Returns:
+ dict: Platform response indicating success or failure, e.g.
+ {"success": True, "message_id": 123}
+ """
+ return await self.perform_action((group_id, message),
+ ActionType.SEND_TO_GROUP.value)
+
+ async def listen_from_group(self):
+ r"""Listen messages from groups"""
+ return await self.perform_action(self.agent_id,
+ ActionType.LISTEN_FROM_GROUP.value)
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/agent_environment.py b/backend/vendor/camel-oasis/oasis/social_agent/agent_environment.py
new file mode 100644
index 00000000..d2051919
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/agent_environment.py
@@ -0,0 +1,135 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+import json
+import sqlite3
+from abc import ABC, abstractmethod
+from string import Template
+
+from oasis.social_agent.agent_action import SocialAction
+from oasis.social_platform.database import get_db_path
+
+
+class Environment(ABC):
+
+ @abstractmethod
+ def to_text_prompt(self) -> str:
+ r"""Convert the environment to text prompt."""
+ raise NotImplementedError
+
+
+class SocialEnvironment(Environment):
+ followers_env_template = Template("I have $num_followers followers.")
+ follows_env_template = Template("I have $num_follows follows.")
+
+ posts_env_template = Template(
+ "After refreshing, you see some posts $posts")
+
+ groups_env_template = Template(
+ "And there are many group chat channels $all_groups\n"
+ "And You are already in some groups $joined_groups\n"
+ "You receive some messages from them $messages\n"
+ "You can join the groups you are interested, "
+ "leave the groups you already in, send messages to the group "
+ "you already in.\n"
+ "You must make sure you can only send messages to the group you "
+ "are already in")
+ env_template = Template(
+ "$groups_env\n"
+ "$posts_env\npick one you want to perform action that best "
+ "reflects your current inclination based on your profile and "
+ "posts content. Do not limit your action in just `like` to like posts")
+
+ def __init__(self, action: SocialAction):
+ self.action = action
+
+ async def get_posts_env(self) -> str:
+ posts = await self.action.refresh()
+ # TODO: Replace posts json format string to other formats
+ if posts["success"]:
+ posts_env = json.dumps(posts["posts"], indent=4)
+ posts_env = self.posts_env_template.substitute(posts=posts_env)
+ else:
+ posts_env = "After refreshing, there are no existing posts."
+ return posts_env
+
+ async def get_followers_env(self) -> str:
+ # TODO: Implement followers env
+ agent_id = self.action.agent_id
+ db_path = get_db_path()
+ try:
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ cursor.execute("SELECT num_followers FROM user WHERE agent_id = ?",
+ (agent_id, ))
+ result = cursor.fetchone()
+ num_followers = result[0] if result else 0
+ conn.close()
+ except Exception:
+ num_followers = 0
+ return self.followers_env_template.substitute(
+ {"num_followers": num_followers})
+
+ async def get_follows_env(self) -> str:
+ # TODO: Implement follows env
+ agent_id = self.action.agent_id
+ try:
+ db_path = get_db_path()
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT num_followings FROM user WHERE agent_id = ?",
+ (agent_id, ))
+ result = cursor.fetchone()
+ num_followings = result[0] if result else 0
+ conn.close()
+ except Exception:
+ num_followings = 0
+ return self.follows_env_template.substitute(
+ {"num_follows": num_followings})
+
+ async def get_group_env(self) -> str:
+ groups = await self.action.listen_from_group()
+ if groups["success"]:
+ all_groups = json.dumps(groups["all_groups"])
+ joined_groups = json.dumps(groups["joined_groups"])
+ messages = json.dumps(groups["messages"])
+ groups_env = self.groups_env_template.substitute(
+ all_groups=all_groups,
+ joined_groups=joined_groups,
+ messages=messages,
+ )
+ else:
+ groups_env = "No groups."
+ return groups_env
+
+ async def to_text_prompt(
+ self,
+ include_posts: bool = True,
+ include_followers: bool = True,
+ include_follows: bool = True,
+ ) -> str:
+ followers_env = (await self.get_followers_env()
+ if include_follows else "No followers.")
+ follows_env = (await self.get_follows_env()
+ if include_followers else "No follows.")
+ posts_env = await self.get_posts_env() if include_posts else ""
+
+ return self.env_template.substitute(
+ followers_env=followers_env,
+ follows_env=follows_env,
+ posts_env=posts_env,
+ groups_env=await self.get_group_env(),
+ )
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/agent_graph.py b/backend/vendor/camel-oasis/oasis/social_agent/agent_graph.py
new file mode 100644
index 00000000..c7819314
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/agent_graph.py
@@ -0,0 +1,292 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+from typing import Any, Literal
+
+import igraph as ig
+from neo4j import GraphDatabase
+
+from oasis.social_agent.agent import SocialAgent
+from oasis.social_platform.config import Neo4jConfig
+
+
+class Neo4jHandler:
+
+ def __init__(self, nei4j_config: Neo4jConfig):
+ self.driver = GraphDatabase.driver(
+ nei4j_config.uri,
+ auth=(nei4j_config.username, nei4j_config.password),
+ )
+ self.driver.verify_connectivity()
+
+ def close(self):
+ self.driver.close()
+
+ def create_agent(self, agent_id: int):
+ with self.driver.session() as session:
+ session.write_transaction(self._create_and_return_agent, agent_id)
+
+ def delete_agent(self, agent_id: int):
+ with self.driver.session() as session:
+ session.write_transaction(
+ self._delete_agent_and_relationships,
+ agent_id,
+ )
+
+ def get_number_of_nodes(self) -> int:
+ with self.driver.session() as session:
+ return session.read_transaction(self._get_number_of_nodes)
+
+ def get_number_of_edges(self) -> int:
+ with self.driver.session() as session:
+ return session.read_transaction(self._get_number_of_edges)
+
+ def add_edge(self, src_agent_id: int, dst_agent_id: int):
+ with self.driver.session() as session:
+ session.write_transaction(
+ self._add_and_return_edge,
+ src_agent_id,
+ dst_agent_id,
+ )
+
+ def remove_edge(self, src_agent_id: int, dst_agent_id: int):
+ with self.driver.session() as session:
+ session.write_transaction(
+ self._remove_and_return_edge,
+ src_agent_id,
+ dst_agent_id,
+ )
+
+ def get_all_nodes(self) -> list[int]:
+ with self.driver.session() as session:
+ return session.read_transaction(self._get_all_nodes)
+
+ def get_all_edges(self) -> list[tuple[int, int]]:
+ with self.driver.session() as session:
+ return session.read_transaction(self._get_all_edges)
+
+ def reset_graph(self):
+ with self.driver.session() as session:
+ session.write_transaction(self._reset_graph)
+
+ @staticmethod
+ def _create_and_return_agent(tx: Any, agent_id: int):
+ query = """
+ CREATE (a:Agent {id: $agent_id})
+ RETURN a
+ """
+ result = tx.run(query, agent_id=agent_id)
+ return result.single()
+
+ @staticmethod
+ def _delete_agent_and_relationships(tx: Any, agent_id: int):
+ query = """
+ MATCH (a:Agent {id: $agent_id})
+ DETACH DELETE a
+ RETURN count(a) AS deleted
+ """
+ result = tx.run(query, agent_id=agent_id)
+ return result.single()
+
+ @staticmethod
+ def _add_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int):
+ query = """
+ MATCH (a:Agent {id: $src_agent_id}), (b:Agent {id: $dst_agent_id})
+ CREATE (a)-[r:FOLLOW]->(b)
+ RETURN r
+ """
+ result = tx.run(query,
+ src_agent_id=src_agent_id,
+ dst_agent_id=dst_agent_id)
+ return result.single()
+
+ @staticmethod
+ def _remove_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int):
+ query = """
+ MATCH (a:Agent {id: $src_agent_id})
+ MATCH (b:Agent {id: $dst_agent_id})
+ MATCH (a)-[r:FOLLOW]->(b)
+ DELETE r
+ RETURN count(r) AS deleted
+ """
+ result = tx.run(query,
+ src_agent_id=src_agent_id,
+ dst_agent_id=dst_agent_id)
+ return result.single()
+
+ @staticmethod
+ def _get_number_of_nodes(tx: Any) -> int:
+ query = """
+ MATCH (n)
+ RETURN count(n) AS num_nodes
+ """
+ result = tx.run(query)
+ return result.single()["num_nodes"]
+
+ @staticmethod
+ def _get_number_of_edges(tx: Any) -> int:
+ query = """
+ MATCH ()-[r]->()
+ RETURN count(r) AS num_edges
+ """
+ result = tx.run(query)
+ return result.single()["num_edges"]
+
+ @staticmethod
+ def _get_all_nodes(tx: Any) -> list[int]:
+ query = """
+ MATCH (a:Agent)
+ RETURN a.id AS agent_id
+ """
+ result = tx.run(query)
+ return [record["agent_id"] for record in result]
+
+ @staticmethod
+ def _get_all_edges(tx: Any) -> list[tuple[int, int]]:
+ query = """
+ MATCH (a:Agent)-[r:FOLLOW]->(b:Agent)
+ RETURN a.id AS src_agent_id, b.id AS dst_agent_id
+ """
+ result = tx.run(query)
+ return [(record["src_agent_id"], record["dst_agent_id"])
+ for record in result]
+
+ @staticmethod
+ def _reset_graph(tx: Any):
+ query = """
+ MATCH (n)
+ DETACH DELETE n
+ """
+ tx.run(query)
+
+
+class AgentGraph:
+ r"""AgentGraph class to manage the social graph of agents."""
+
+ def __init__(
+ self,
+ backend: Literal["igraph", "neo4j"] = "igraph",
+ neo4j_config: Neo4jConfig | None = None,
+ ):
+ self.backend = backend
+ if self.backend == "igraph":
+ self.graph = ig.Graph(directed=True)
+ else:
+ assert neo4j_config is not None
+ assert neo4j_config.is_valid()
+ self.graph = Neo4jHandler(neo4j_config)
+ self.agent_mappings: dict[int, SocialAgent] = {}
+
+ def reset(self):
+ if self.backend == "igraph":
+ self.graph = ig.Graph(directed=True)
+ else:
+ self.graph.reset_graph()
+ self.agent_mappings: dict[int, SocialAgent] = {}
+
+ def add_agent(self, agent: SocialAgent):
+ if self.backend == "igraph":
+ self.graph.add_vertex(agent.social_agent_id)
+ else:
+ self.graph.create_agent(agent.social_agent_id)
+ self.agent_mappings[agent.social_agent_id] = agent
+
+ def add_edge(self, agent_id_0: int, agent_id_1: int):
+ try:
+ self.graph.add_edge(agent_id_0, agent_id_1)
+ except Exception:
+ pass
+
+ def remove_agent(self, agent: SocialAgent):
+ if self.backend == "igraph":
+ self.graph.delete_vertices(agent.social_agent_id)
+ else:
+ self.graph.delete_agent(agent.social_agent_id)
+ del self.agent_mappings[agent.social_agent_id]
+
+ def remove_edge(self, agent_id_0: int, agent_id_1: int):
+ if self.backend == "igraph":
+ if self.graph.are_connected(agent_id_0, agent_id_1):
+ self.graph.delete_edges([(agent_id_0, agent_id_1)])
+ else:
+ self.graph.remove_edge(agent_id_0, agent_id_1)
+
+ def get_agent(self, agent_id: int) -> SocialAgent:
+ return self.agent_mappings[agent_id]
+
+ def get_agents(
+ self,
+ agent_ids: list[int] = None) -> list[tuple[int, SocialAgent]]:
+ if agent_ids:
+ return [(agent_id, self.get_agent(agent_id))
+ for agent_id in agent_ids]
+ if self.backend == "igraph":
+ return [(node.index, self.agent_mappings[node.index])
+ for node in self.graph.vs]
+ else:
+ return [(agent_id, self.agent_mappings[agent_id])
+ for agent_id in self.graph.get_all_nodes()]
+
+ def get_edges(self) -> list[tuple[int, int]]:
+ if self.backend == "igraph":
+ return [(edge.source, edge.target) for edge in self.graph.es]
+ else:
+ return self.graph.get_all_edges()
+
+ def get_num_nodes(self) -> int:
+ if self.backend == "igraph":
+ return self.graph.vcount()
+ else:
+ return self.graph.get_number_of_nodes()
+
+ def get_num_edges(self) -> int:
+ if self.backend == "igraph":
+ return self.graph.ecount()
+ else:
+ return self.graph.get_number_of_edges()
+
+ def close(self) -> None:
+ if self.backend == "neo4j":
+ self.graph.close()
+
+ def visualize(
+ self,
+ path: str,
+ vertex_size: int = 20,
+ edge_arrow_size: float = 0.5,
+ with_labels: bool = True,
+ vertex_color: str = "#f74f1b",
+ vertex_frame_width: int = 2,
+ width: int = 1000,
+ height: int = 1000,
+ ):
+ if self.backend == "neo4j":
+ raise ValueError("Neo4j backend does not support visualization.")
+ layout = self.graph.layout("auto")
+ if with_labels:
+ labels = [node_id for node_id, _ in self.get_agents()]
+ else:
+ labels = None
+ ig.plot(
+ self.graph,
+ target=path,
+ layout=layout,
+ vertex_label=labels,
+ vertex_size=vertex_size,
+ vertex_color=vertex_color,
+ edge_arrow_size=edge_arrow_size,
+ vertex_frame_width=vertex_frame_width,
+ bbox=(width, height),
+ )
diff --git a/backend/vendor/camel-oasis/oasis/social_agent/agents_generator.py b/backend/vendor/camel-oasis/oasis/social_agent/agents_generator.py
new file mode 100644
index 00000000..65435906
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_agent/agents_generator.py
@@ -0,0 +1,649 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+import ast
+import asyncio
+import json
+from typing import List, Optional, Union
+
+import pandas as pd
+import tqdm
+from camel.memories import MemoryRecord
+from camel.messages import BaseMessage
+from camel.models import BaseModelBackend, ModelManager
+from camel.types import OpenAIBackendRole
+
+from oasis.social_agent import AgentGraph, SocialAgent
+from oasis.social_platform import Channel, Platform
+from oasis.social_platform.config import Neo4jConfig, UserInfo
+from oasis.social_platform.typing import ActionType
+
+
+async def generate_agents(
+ agent_info_path: str,
+ channel: Channel,
+ model: Union[BaseModelBackend, List[BaseModelBackend]],
+ start_time,
+ recsys_type: str = "twitter",
+ twitter: Platform = None,
+ available_actions: list[ActionType] = None,
+ neo4j_config: Neo4jConfig | None = None,
+) -> AgentGraph:
+ """TODO: need update the description of args and check
+ Generate and return a dictionary of agents from the agent
+ information CSV file. Each agent is added to the database and
+ their respective profiles are updated.
+
+ Args:
+ agent_info_path (str): The file path to the agent information CSV file.
+ channel (Channel): Information channel.
+ action_space_prompt (str): determine the action space of agents.
+ model_random_seed (int): Random seed to randomly assign model to
+ each agent. (default: 42)
+ cfgs (list, optional): List of configuration. (default: `None`)
+ neo4j_config (Neo4jConfig, optional): Neo4j graph database
+ configuration. (default: `None`)
+
+ Returns:
+ dict: A dictionary of agent IDs mapped to their respective agent
+ class instances.
+ """
+ agent_info = pd.read_csv(agent_info_path)
+
+ agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph(
+ backend="neo4j",
+ neo4j_config=neo4j_config,
+ ))
+
+ # agent_graph = []
+ sign_up_list = []
+ follow_list = []
+ user_update1 = []
+ user_update2 = []
+ post_list = []
+
+ for agent_id in range(len(agent_info)):
+ profile = {
+ "nodes": [],
+ "edges": [],
+ "other_info": {},
+ }
+ profile["other_info"]["user_profile"] = agent_info["user_char"][
+ agent_id]
+
+ user_info = UserInfo(
+ name=agent_info["username"][agent_id],
+ description=agent_info["description"][agent_id],
+ profile=profile,
+ recsys_type=recsys_type,
+ )
+
+ agent = SocialAgent(
+ agent_id=agent_id,
+ user_info=user_info,
+ channel=channel,
+ model=model,
+ agent_graph=agent_graph,
+ available_actions=available_actions,
+ )
+
+ agent_graph.add_agent(agent)
+ # TODO we should not use following_count and followers_count
+ # We should calculate the number of followings and followers
+ # based on the graph because the following situation is dynamic.
+ num_followings = 0
+ num_followers = 0
+
+ sign_up_list.append((
+ agent_id,
+ agent_id,
+ agent_info["username"][agent_id],
+ agent_info["name"][agent_id],
+ agent_info["description"][agent_id],
+ start_time,
+ num_followings,
+ num_followers,
+ ))
+
+ following_id_list = ast.literal_eval(
+ agent_info["following_agentid_list"][agent_id])
+ if not isinstance(following_id_list, int):
+ if len(following_id_list) != 0:
+ for follow_id in following_id_list:
+ follow_list.append((agent_id, follow_id, start_time))
+ user_update1.append((agent_id, ))
+ user_update2.append((follow_id, ))
+ agent_graph.add_edge(agent_id, follow_id)
+
+ previous_posts = ast.literal_eval(
+ agent_info["previous_tweets"][agent_id])
+ if len(previous_posts) != 0:
+ for post in previous_posts:
+ post_list.append((agent_id, post, start_time, 0, 0))
+
+ # generate_log.info('agent gegenerate finished.')
+
+ user_insert_query = (
+ "INSERT INTO user (user_id, agent_id, user_name, name, bio, "
+ "created_at, num_followings, num_followers) VALUES "
+ "(?, ?, ?, ?, ?, ?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(user_insert_query,
+ sign_up_list,
+ commit=True)
+
+ follow_insert_query = (
+ "INSERT INTO follow (follower_id, followee_id, created_at) "
+ "VALUES (?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(follow_insert_query,
+ follow_list,
+ commit=True)
+ user_update_query1 = (
+ "UPDATE user SET num_followings = num_followings + 1 "
+ "WHERE user_id = ?")
+ twitter.pl_utils._execute_many_db_command(user_update_query1,
+ user_update1,
+ commit=True)
+
+ user_update_query2 = ("UPDATE user SET num_followers = num_followers + 1 "
+ "WHERE user_id = ?")
+ twitter.pl_utils._execute_many_db_command(user_update_query2,
+ user_update2,
+ commit=True)
+
+ # generate_log.info('twitter followee update finished.')
+
+ post_insert_query = (
+ "INSERT INTO post (user_id, content, created_at, num_likes, "
+ "num_dislikes) VALUES (?, ?, ?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(post_insert_query,
+ post_list,
+ commit=True)
+
+ # generate_log.info('twitter creat post finished.')
+
+ return agent_graph
+
+
+async def generate_agents_100w(
+ agent_info_path: str,
+ channel: Channel,
+ start_time,
+ model: Union[BaseModelBackend, List[BaseModelBackend]],
+ recsys_type: str = "twitter",
+ twitter: Platform = None,
+ available_actions: list[ActionType] = None,
+) -> List:
+ """ TODO: need update the description of args.
+ Generate and return a dictionary of agents from the agent
+ information CSV file. Each agent is added to the database and
+ their respective profiles are updated.
+
+ Args:
+ agent_info_path (str): The file path to the agent information CSV file.
+ channel (Channel): Information channel.
+ action_space_prompt (str): determine the action space of agents.
+ model_random_seed (int): Random seed to randomly assign model to
+ each agent. (default: 42)
+
+ Returns:
+ dict: A dictionary of agent IDs mapped to their respective agent
+ class instances.
+ """
+ agent_info = pd.read_csv(agent_info_path)
+
+ # TODO when setting 100w agents, the agentgraph class is too slow.
+ # I use the list.
+ agent_graph = []
+ # agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph(
+ # backend="neo4j",
+ # neo4j_config=neo4j_config,
+ # ))
+
+ # agent_graph = []
+ sign_up_list = []
+ follow_list = []
+ user_update1 = []
+ user_update2 = []
+ post_list = []
+
+ # precompute to speed up agent generation in one million scale
+ _ = agent_info["following_agentid_list"].apply(ast.literal_eval)
+ previous_tweets_lists = agent_info["previous_tweets"].apply(
+ ast.literal_eval)
+ previous_tweets_lists = agent_info['previous_tweets'].apply(
+ ast.literal_eval)
+ following_id_lists = agent_info["following_agentid_list"].apply(
+ ast.literal_eval)
+
+ for agent_id in tqdm.tqdm(range(len(agent_info))):
+ profile = {
+ "nodes": [],
+ "edges": [],
+ "other_info": {},
+ }
+ profile["other_info"]["user_profile"] = agent_info["user_char"][
+ agent_id]
+ # TODO if you simulate one million agents, use active threshold below.
+ # profile['other_info']['active_threshold'] = [0.01] * 24
+
+ user_info = UserInfo(
+ name=agent_info["username"][agent_id],
+ description=agent_info["description"][agent_id],
+ profile=profile,
+ recsys_type=recsys_type,
+ )
+
+ agent = SocialAgent(
+ agent_id=agent_id,
+ user_info=user_info,
+ channel=channel,
+ model=model,
+ agent_graph=agent_graph,
+ available_actions=available_actions,
+ )
+
+ agent_graph.append(agent)
+ num_followings = 0
+ num_followers = 0
+ # print('agent_info["following_count"]', agent_info["following_count"])
+
+ # TODO some data does not cotain this key.
+ if 'following_count' not in agent_info.columns:
+ agent_info['following_count'] = 0
+ if 'followers_count' not in agent_info.columns:
+ agent_info['followers_count'] = 0
+
+ if not agent_info["following_count"].empty:
+ num_followings = agent_info["following_count"][agent_id]
+ if not agent_info["followers_count"].empty:
+ num_followers = agent_info["followers_count"][agent_id]
+
+ sign_up_list.append((
+ agent_id,
+ agent_id,
+ agent_info["username"][agent_id],
+ agent_info["name"][agent_id],
+ agent_info["description"][agent_id],
+ start_time,
+ num_followings,
+ num_followers,
+ ))
+
+ following_id_list = following_id_lists[agent_id]
+
+ # TODO If we simulate 1 million agents, we can not use agent_graph
+ # class. It is not scalble.
+ if not isinstance(following_id_list, int):
+ if len(following_id_list) != 0:
+ for follow_id in following_id_list:
+ follow_list.append((agent_id, follow_id, start_time))
+ user_update1.append((agent_id, ))
+ user_update2.append((follow_id, ))
+ # agent_graph.add_edge(agent_id, follow_id)
+
+ previous_posts = previous_tweets_lists[agent_id]
+ if len(previous_posts) != 0:
+ for post in previous_posts:
+ post_list.append((agent_id, post, start_time, 0, 0))
+
+ # generate_log.info('agent gegenerate finished.')
+
+ user_insert_query = (
+ "INSERT INTO user (user_id, agent_id, user_name, name, bio, "
+ "created_at, num_followings, num_followers) VALUES "
+ "(?, ?, ?, ?, ?, ?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(user_insert_query,
+ sign_up_list,
+ commit=True)
+
+ follow_insert_query = (
+ "INSERT INTO follow (follower_id, followee_id, created_at) "
+ "VALUES (?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(follow_insert_query,
+ follow_list,
+ commit=True)
+
+ if not (agent_info["following_count"].empty
+ and agent_info["followers_count"].empty):
+ user_update_query1 = (
+ "UPDATE user SET num_followings = num_followings + 1 "
+ "WHERE user_id = ?")
+ twitter.pl_utils._execute_many_db_command(user_update_query1,
+ user_update1,
+ commit=True)
+
+ user_update_query2 = (
+ "UPDATE user SET num_followers = num_followers + 1 "
+ "WHERE user_id = ?")
+ twitter.pl_utils._execute_many_db_command(user_update_query2,
+ user_update2,
+ commit=True)
+
+ # generate_log.info('twitter followee update finished.')
+
+ post_insert_query = (
+ "INSERT INTO post (user_id, content, created_at, num_likes, "
+ "num_dislikes) VALUES (?, ?, ?, ?, ?)")
+ twitter.pl_utils._execute_many_db_command(post_insert_query,
+ post_list,
+ commit=True)
+
+ # generate_log.info('twitter creat post finished.')
+
+ return agent_graph
+
+
+async def generate_controllable_agents(
+ channel: Channel,
+ control_user_num: int,
+) -> tuple[AgentGraph, dict]:
+ agent_graph = AgentGraph()
+ agent_user_id_mapping = {}
+ for i in range(control_user_num):
+ user_info = UserInfo(
+ is_controllable=True,
+ profile={"other_info": {
+ "user_profile": "None"
+ }},
+ recsys_type="reddit",
+ )
+ # controllable的agent_id全都在llm agent的agent_id的前面
+ agent = SocialAgent(agent_id=i,
+ user_info=user_info,
+ channel=channel,
+ agent_graph=agent_graph)
+ # Add agent to the agent graph
+ agent_graph.add_agent(agent)
+
+ username = input(f"Please input username for agent {i}: ")
+ name = input(f"Please input name for agent {i}: ")
+ bio = input(f"Please input bio for agent {i}: ")
+
+ response = await agent.env.action.sign_up(username, name, bio)
+ user_id = response["user_id"]
+ agent_user_id_mapping[i] = user_id
+
+ for i in range(control_user_num):
+ for j in range(control_user_num):
+ agent = agent_graph.get_agent(i)
+ # controllable agent互相也全部关注
+ if i != j:
+ user_id = agent_user_id_mapping[j]
+ await agent.env.action.follow(user_id)
+ agent_graph.add_edge(i, j)
+ return agent_graph, agent_user_id_mapping
+
+
+async def gen_control_agents_with_data(
+ channel: Channel,
+ control_user_num: int,
+ models: list[BaseModelBackend] | None = None,
+) -> tuple[AgentGraph, dict]:
+ agent_graph = AgentGraph()
+ agent_user_id_mapping = {}
+ for i in range(control_user_num):
+ user_info = UserInfo(
+ is_controllable=True,
+ profile={
+ "other_info": {
+ "user_profile": "None",
+ "gender": "None",
+ "mbti": "None",
+ "country": "None",
+ "age": "None",
+ }
+ },
+ recsys_type="reddit",
+ )
+ # controllable的agent_id全都在llm agent的agent_id的前面
+ agent = SocialAgent(
+ agent_id=i,
+ user_info=user_info,
+ channel=channel,
+ agent_graph=agent_graph,
+ model=models,
+ available_actions=None,
+ )
+ # Add agent to the agent graph
+ agent_graph.add_agent(agent)
+ user_name = "momo"
+ name = "momo"
+ bio = "None."
+ response = await agent.env.action.sign_up(user_name, name, bio)
+ user_id = response["user_id"]
+ agent_user_id_mapping[i] = user_id
+
+ return agent_graph, agent_user_id_mapping
+
+
+async def generate_reddit_agents(
+ agent_info_path: str,
+ channel: Channel,
+ agent_graph: AgentGraph | None = None,
+ agent_user_id_mapping: dict[int, int] | None = None,
+ follow_post_agent: bool = False,
+ mute_post_agent: bool = False,
+ model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
+ ModelManager]] = None,
+ available_actions: list[ActionType] = None,
+) -> AgentGraph:
+ if agent_user_id_mapping is None:
+ agent_user_id_mapping = {}
+ if agent_graph is None:
+ agent_graph = AgentGraph()
+
+ control_user_num = agent_graph.get_num_nodes()
+
+ with open(agent_info_path, "r") as file:
+ agent_info = json.load(file)
+
+ async def process_agent(i):
+ # Instantiate an agent
+ profile = {
+ "nodes": [], # Relationships with other agents
+ "edges": [], # Relationship details
+ "other_info": {},
+ }
+ # Update agent profile with additional information
+ profile["other_info"]["user_profile"] = agent_info[i]["persona"]
+ profile["other_info"]["mbti"] = agent_info[i]["mbti"]
+ profile["other_info"]["gender"] = agent_info[i]["gender"]
+ profile["other_info"]["age"] = agent_info[i]["age"]
+ profile["other_info"]["country"] = agent_info[i]["country"]
+
+ user_info = UserInfo(
+ name=agent_info[i]["username"],
+ description=agent_info[i]["bio"],
+ profile=profile,
+ recsys_type="reddit",
+ )
+
+ agent = SocialAgent(
+ agent_id=i + control_user_num,
+ user_info=user_info,
+ channel=channel,
+ agent_graph=agent_graph,
+ model=model,
+ available_actions=available_actions,
+ )
+
+ # Add agent to the agent graph
+ agent_graph.add_agent(agent)
+
+ # Sign up agent and add their information to the database
+ # print(f"Signing up agent {agent_info['username'][i]}...")
+ response = await agent.env.action.sign_up(agent_info[i]["username"],
+ agent_info[i]["realname"],
+ agent_info[i]["bio"])
+ user_id = response["user_id"]
+ agent_user_id_mapping[i + control_user_num] = user_id
+
+ if follow_post_agent:
+ await agent.env.action.follow(1)
+ content = """
+{
+ "reason": "He is my friend, and I would like to follow him "
+ "on social media.",
+ "functions": [
+ {
+ "name": "follow",
+ "arguments": {
+ "user_id": 1
+ }
+ }
+ ]
+}
+"""
+
+ agent_msg = BaseMessage.make_assistant_message(
+ role_name="Assistant", content=content)
+ agent.memory.write_record(
+ MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT))
+ elif mute_post_agent:
+ await agent.env.action.mute(1)
+ content = """
+{
+ "reason": "He is my enemy, and I would like to mute him on social media.",
+ "functions": [{
+ "name": "mute",
+ "arguments": {
+ "user_id": 1
+ }
+}
+"""
+ agent_msg = BaseMessage.make_assistant_message(
+ role_name="Assistant", content=content)
+ agent.memory.write_record(
+ MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT))
+
+ tasks = [process_agent(i) for i in range(len(agent_info))]
+ await asyncio.gather(*tasks)
+
+ return agent_graph
+
+
+def connect_platform_channel(
+ channel: Channel,
+ agent_graph: AgentGraph | None = None,
+) -> AgentGraph:
+ for _, agent in agent_graph.get_agents():
+ agent.channel = channel
+ agent.env.action.channel = channel
+ return agent_graph
+
+
+async def generate_custom_agents(
+ channel: Channel,
+ agent_graph: AgentGraph | None = None,
+) -> AgentGraph:
+ if agent_graph is None:
+ agent_graph = AgentGraph()
+
+ agent_graph = connect_platform_channel(channel=channel,
+ agent_graph=agent_graph)
+
+ sign_up_tasks = [
+ agent.env.action.sign_up(user_name=agent.user_info.user_name,
+ name=agent.user_info.name,
+ bio=agent.user_info.description)
+ for _, agent in agent_graph.get_agents()
+ ]
+ await asyncio.gather(*sign_up_tasks)
+ return agent_graph
+
+
+async def generate_reddit_agent_graph(
+ profile_path: str,
+ model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
+ ModelManager]] = None,
+ available_actions: list[ActionType] = None,
+) -> AgentGraph:
+ agent_graph = AgentGraph()
+ with open(profile_path, "r") as file:
+ agent_info = json.load(file)
+
+ async def process_agent(i):
+ # Instantiate an agent
+ profile = {
+ "nodes": [], # Relationships with other agents
+ "edges": [], # Relationship details
+ "other_info": {},
+ }
+ # Update agent profile with additional information
+ profile["other_info"]["user_profile"] = agent_info[i]["persona"]
+ profile["other_info"]["mbti"] = agent_info[i]["mbti"]
+ profile["other_info"]["gender"] = agent_info[i]["gender"]
+ profile["other_info"]["age"] = agent_info[i]["age"]
+ profile["other_info"]["country"] = agent_info[i]["country"]
+
+ user_info = UserInfo(
+ name=agent_info[i]["username"],
+ description=agent_info[i]["bio"],
+ profile=profile,
+ recsys_type="reddit",
+ )
+
+ agent = SocialAgent(
+ agent_id=i,
+ user_info=user_info,
+ agent_graph=agent_graph,
+ model=model,
+ available_actions=available_actions,
+ )
+
+ # Add agent to the agent graph
+ agent_graph.add_agent(agent)
+
+ tasks = [process_agent(i) for i in range(len(agent_info))]
+ await asyncio.gather(*tasks)
+ return agent_graph
+
+
+async def generate_twitter_agent_graph(
+ profile_path: str,
+ model: Optional[Union[BaseModelBackend, List[BaseModelBackend],
+ ModelManager]] = None,
+ available_actions: list[ActionType] = None,
+) -> AgentGraph:
+ agent_info = pd.read_csv(profile_path)
+
+ agent_graph = AgentGraph()
+
+ for agent_id in range(len(agent_info)):
+ profile = {
+ "nodes": [],
+ "edges": [],
+ "other_info": {},
+ }
+ profile["other_info"]["user_profile"] = agent_info["user_char"][
+ agent_id]
+
+ user_info = UserInfo(
+ name=agent_info["username"][agent_id],
+ description=agent_info["description"][agent_id],
+ profile=profile,
+ recsys_type='twitter',
+ )
+
+ agent = SocialAgent(
+ agent_id=agent_id,
+ user_info=user_info,
+ model=model,
+ agent_graph=agent_graph,
+ available_actions=available_actions,
+ )
+
+ agent_graph.add_agent(agent)
+ return agent_graph
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/__init__.py b/backend/vendor/camel-oasis/oasis/social_platform/__init__.py
new file mode 100644
index 00000000..83c0aca4
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/__init__.py
@@ -0,0 +1,20 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from .channel import Channel
+from .platform import Platform
+
+__all__ = [
+ "Channel",
+ "Platform",
+]
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/channel.py b/backend/vendor/camel-oasis/oasis/social_platform/channel.py
new file mode 100644
index 00000000..7d29f053
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/channel.py
@@ -0,0 +1,71 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+import asyncio
+import uuid
+
+
+class AsyncSafeDict:
+
+ def __init__(self):
+ self.dict = {}
+ self.lock = asyncio.Lock()
+
+ async def put(self, key, value):
+ async with self.lock:
+ self.dict[key] = value
+
+ async def get(self, key, default=None):
+ async with self.lock:
+ return self.dict.get(key, default)
+
+ async def pop(self, key, default=None):
+ async with self.lock:
+ return self.dict.pop(key, default)
+
+ async def keys(self):
+ async with self.lock:
+ return list(self.dict.keys())
+
+
+class Channel:
+
+ def __init__(self):
+ self.receive_queue = asyncio.Queue() # Used to store received messages
+ # Using an asynchronous safe dictionary to store messages to be sent
+ self.send_dict = AsyncSafeDict()
+
+ async def receive_from(self):
+ message = await self.receive_queue.get()
+ return message
+
+ async def send_to(self, message):
+ # message_id is the first element of the message
+ message_id = message[0]
+ await self.send_dict.put(message_id, message)
+
+ async def write_to_receive_queue(self, action_info):
+ message_id = str(uuid.uuid4())
+ await self.receive_queue.put((message_id, action_info))
+ return message_id
+
+ async def read_from_send_queue(self, message_id):
+ while True:
+ if message_id in await self.send_dict.keys():
+ # Attempting to retrieve the message
+ message = await self.send_dict.pop(message_id, None)
+ if message:
+ return message # Return the found message
+ # Temporarily suspend to avoid tight looping
+ await asyncio.sleep(
+ 0.1) # set a large one to reduce the workload of cpu
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/config/__init__.py b/backend/vendor/camel-oasis/oasis/social_platform/config/__init__.py
new file mode 100644
index 00000000..8bfb9b4d
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/config/__init__.py
@@ -0,0 +1,20 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from .neo4j import Neo4jConfig
+from .user import UserInfo
+
+__all__ = [
+ "UserInfo",
+ "Neo4jConfig",
+]
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/config/neo4j.py b/backend/vendor/camel-oasis/oasis/social_platform/config/neo4j.py
new file mode 100644
index 00000000..af5aacd5
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/config/neo4j.py
@@ -0,0 +1,24 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from dataclasses import dataclass
+
+
+@dataclass
+class Neo4jConfig:
+ uri: str | None = None
+ username: str | None = None
+ password: str | None = None
+
+ def is_valid(self) -> bool:
+ return all([self.uri, self.username, self.password])
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/config/user.py b/backend/vendor/camel-oasis/oasis/social_platform/config/user.py
new file mode 100644
index 00000000..a36b4253
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/config/user.py
@@ -0,0 +1,111 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# flake8: noqa: E501
+import warnings
+from dataclasses import dataclass
+from typing import Any
+
+from camel.prompts import TextPrompt
+
+
+@dataclass
+class UserInfo:
+ user_name: str | None = None
+ name: str | None = None
+ description: str | None = None
+ profile: dict[str, Any] | None = None
+ recsys_type: str = "twitter"
+ is_controllable: bool = False
+
+ def to_custom_system_message(self, user_info_template: TextPrompt) -> str:
+ required_keys = user_info_template.key_words
+ info_keys = set(self.profile.keys())
+ missing = required_keys - info_keys
+ extra = info_keys - required_keys
+ if missing:
+ raise ValueError(
+ f"Missing required keys in UserInfo.profile: {missing}")
+ if extra:
+ warnings.warn(f"Extra keys not used in UserInfo.profile: {extra}")
+
+ return user_info_template.format(**self.profile)
+
+ def to_system_message(self) -> str:
+ if self.recsys_type != "reddit":
+ return self.to_twitter_system_message()
+ else:
+ return self.to_reddit_system_message()
+
+ def to_twitter_system_message(self) -> str:
+ name_string = ""
+ description_string = ""
+ if self.name is not None:
+ name_string = f"Your name is {self.name}."
+ if self.profile is None:
+ description = name_string
+ elif "other_info" not in self.profile:
+ description = name_string
+ elif "user_profile" in self.profile["other_info"]:
+ if self.profile["other_info"]["user_profile"] is not None:
+ user_profile = self.profile["other_info"]["user_profile"]
+ description_string = f"Your have profile: {user_profile}."
+ description = f"{name_string}\n{description_string}"
+
+ system_content = f"""
+# OBJECTIVE
+You're a Twitter user, and I'll present you with some posts. After you see the posts, choose some actions from the following functions.
+
+# SELF-DESCRIPTION
+Your actions should be consistent with your self-description and personality.
+{description}
+
+# RESPONSE METHOD
+Please perform actions by tool calling.
+ """
+
+ return system_content
+
+ def to_reddit_system_message(self) -> str:
+ name_string = ""
+ description_string = ""
+ if self.name is not None:
+ name_string = f"Your name is {self.name}."
+ if self.profile is None:
+ description = name_string
+ elif "other_info" not in self.profile:
+ description = name_string
+ elif "user_profile" in self.profile["other_info"]:
+ if self.profile["other_info"]["user_profile"] is not None:
+ user_profile = self.profile["other_info"]["user_profile"]
+ description_string = f"Your have profile: {user_profile}."
+ description = f"{name_string}\n{description_string}"
+ print(self.profile['other_info'])
+ description += (
+ f"You are a {self.profile['other_info']['gender']}, "
+ f"{self.profile['other_info']['age']} years old, with an MBTI "
+ f"personality type of {self.profile['other_info']['mbti']} from "
+ f"{self.profile['other_info']['country']}.")
+
+ system_content = f"""
+# OBJECTIVE
+You're a Reddit user, and I'll present you with some tweets. After you see the tweets, choose some actions from the following functions.
+
+# SELF-DESCRIPTION
+Your actions should be consistent with your self-description and personality.
+{description}
+
+# RESPONSE METHOD
+Please perform actions by tool calling.
+"""
+ return system_content
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/database.py b/backend/vendor/camel-oasis/oasis/social_platform/database.py
new file mode 100644
index 00000000..4d8f83a8
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/database.py
@@ -0,0 +1,291 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+import os
+import os.path as osp
+import sqlite3
+from typing import Any, Dict, List
+
+SCHEMA_DIR = "social_platform/schema"
+DB_DIR = "data"
+DB_NAME = "social_media.db"
+
+USER_SCHEMA_SQL = "user.sql"
+POST_SCHEMA_SQL = "post.sql"
+FOLLOW_SCHEMA_SQL = "follow.sql"
+MUTE_SCHEMA_SQL = "mute.sql"
+LIKE_SCHEMA_SQL = "like.sql"
+DISLIKE_SCHEMA_SQL = "dislike.sql"
+REPORT_SCHEAM_SQL = "report.sql"
+TRACE_SCHEMA_SQL = "trace.sql"
+REC_SCHEMA_SQL = "rec.sql"
+COMMENT_SCHEMA_SQL = "comment.sql"
+COMMENT_LIKE_SCHEMA_SQL = "comment_like.sql"
+COMMENT_DISLIKE_SCHEMA_SQL = "comment_dislike.sql"
+PRODUCT_SCHEMA_SQL = "product.sql"
+GROUP_SCHEMA_SQL = "chat_group.sql"
+GROUP_MEMBER_SCHEMA_SQL = "group_member.sql"
+GROUP_MESSAGE_SCHEMA_SQL = "group_message.sql"
+
+TABLE_NAMES = {
+ "user",
+ "post",
+ "follow",
+ "mute",
+ "like",
+ "dislike",
+ "report",
+ "trace",
+ "rec",
+ "comment.sql",
+ "comment_like.sql",
+ "comment_dislike.sql",
+ "product.sql",
+ "group",
+ "group_member",
+ "group_message",
+}
+
+
+def get_db_path() -> str:
+ # First check if the database path is set in environment variables
+ env_db_path = os.environ.get("OASIS_DB_PATH")
+ if env_db_path:
+ return env_db_path
+
+ # If no environment variable is set, use the original default path
+ curr_file_path = osp.abspath(__file__)
+ parent_dir = osp.dirname(osp.dirname(curr_file_path))
+ db_dir = osp.join(parent_dir, DB_DIR)
+ os.makedirs(db_dir, exist_ok=True)
+ db_path = osp.join(db_dir, DB_NAME)
+ return db_path
+
+
+def get_schema_dir_path() -> str:
+ curr_file_path = osp.abspath(__file__)
+ parent_dir = osp.dirname(osp.dirname(curr_file_path))
+ schema_dir = osp.join(parent_dir, SCHEMA_DIR)
+ return schema_dir
+
+
+def create_db(db_path: str | None = None):
+ r"""Create the database if it does not exist. A :obj:`twitter.db`
+ file will be automatically created in the :obj:`data` directory.
+ """
+ schema_dir = get_schema_dir_path()
+ if db_path is None:
+ db_path = get_db_path()
+
+ # Connect to the database:
+ print("db_path", db_path)
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ try:
+ # Read and execute the user table SQL script:
+ user_sql_path = osp.join(schema_dir, USER_SCHEMA_SQL)
+ with open(user_sql_path, "r") as sql_file:
+ user_sql_script = sql_file.read()
+ cursor.executescript(user_sql_script)
+
+ # Read and execute the post table SQL script:
+ post_sql_path = osp.join(schema_dir, POST_SCHEMA_SQL)
+ with open(post_sql_path, "r") as sql_file:
+ post_sql_script = sql_file.read()
+ cursor.executescript(post_sql_script)
+
+ # Read and execute the follow table SQL script:
+ follow_sql_path = osp.join(schema_dir, FOLLOW_SCHEMA_SQL)
+ with open(follow_sql_path, "r") as sql_file:
+ follow_sql_script = sql_file.read()
+ cursor.executescript(follow_sql_script)
+
+ # Read and execute the mute table SQL script:
+ mute_sql_path = osp.join(schema_dir, MUTE_SCHEMA_SQL)
+ with open(mute_sql_path, "r") as sql_file:
+ mute_sql_script = sql_file.read()
+ cursor.executescript(mute_sql_script)
+
+ # Read and execute the like table SQL script:
+ like_sql_path = osp.join(schema_dir, LIKE_SCHEMA_SQL)
+ with open(like_sql_path, "r") as sql_file:
+ like_sql_script = sql_file.read()
+ cursor.executescript(like_sql_script)
+
+ # Read and execute the dislike table SQL script:
+ dislike_sql_path = osp.join(schema_dir, DISLIKE_SCHEMA_SQL)
+ with open(dislike_sql_path, "r") as sql_file:
+ dislike_sql_script = sql_file.read()
+ cursor.executescript(dislike_sql_script)
+
+ # Read and execute the report table SQL script:
+ report_sql_path = osp.join(schema_dir, REPORT_SCHEAM_SQL)
+ with open(report_sql_path, "r") as sql_file:
+ report_sql_script = sql_file.read()
+ cursor.executescript(report_sql_script)
+
+ # Read and execute the trace table SQL script:
+ trace_sql_path = osp.join(schema_dir, TRACE_SCHEMA_SQL)
+ with open(trace_sql_path, "r") as sql_file:
+ trace_sql_script = sql_file.read()
+ cursor.executescript(trace_sql_script)
+
+ # Read and execute the rec table SQL script:
+ rec_sql_path = osp.join(schema_dir, REC_SCHEMA_SQL)
+ with open(rec_sql_path, "r") as sql_file:
+ rec_sql_script = sql_file.read()
+ cursor.executescript(rec_sql_script)
+
+ # Read and execute the comment table SQL script:
+ comment_sql_path = osp.join(schema_dir, COMMENT_SCHEMA_SQL)
+ with open(comment_sql_path, "r") as sql_file:
+ comment_sql_script = sql_file.read()
+ cursor.executescript(comment_sql_script)
+
+ # Read and execute the comment_like table SQL script:
+ comment_like_sql_path = osp.join(schema_dir, COMMENT_LIKE_SCHEMA_SQL)
+ with open(comment_like_sql_path, "r") as sql_file:
+ comment_like_sql_script = sql_file.read()
+ cursor.executescript(comment_like_sql_script)
+
+ # Read and execute the comment_dislike table SQL script:
+ comment_dislike_sql_path = osp.join(schema_dir,
+ COMMENT_DISLIKE_SCHEMA_SQL)
+ with open(comment_dislike_sql_path, "r") as sql_file:
+ comment_dislike_sql_script = sql_file.read()
+ cursor.executescript(comment_dislike_sql_script)
+
+ # Read and execute the product table SQL script:
+ product_sql_path = osp.join(schema_dir, PRODUCT_SCHEMA_SQL)
+ with open(product_sql_path, "r") as sql_file:
+ product_sql_script = sql_file.read()
+ cursor.executescript(product_sql_script)
+
+ # Read and execute the group table SQL script:
+ group_sql_path = osp.join(schema_dir, GROUP_SCHEMA_SQL)
+ with open(group_sql_path, "r") as sql_file:
+ group_sql_script = sql_file.read()
+ cursor.executescript(group_sql_script)
+
+ # Read and execute the group_member table SQL script:
+ group_member_sql_path = osp.join(schema_dir, GROUP_MEMBER_SCHEMA_SQL)
+ with open(group_member_sql_path, "r") as sql_file:
+ group_member_sql_script = sql_file.read()
+ cursor.executescript(group_member_sql_script)
+
+ # Read and execute the group_message table SQL script:
+ group_message_sql_path = osp.join(schema_dir, GROUP_MESSAGE_SCHEMA_SQL)
+ with open(group_message_sql_path, "r") as sql_file:
+ group_message_sql_script = sql_file.read()
+ cursor.executescript(group_message_sql_script)
+
+ # Commit the changes:
+ conn.commit()
+
+ except sqlite3.Error as e:
+ print(f"An error occurred while creating tables: {e}")
+
+ return conn, cursor
+
+
+def print_db_tables_summary():
+ # Connect to the SQLite database
+ db_path = get_db_path()
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ # Retrieve a list of all tables in the database
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
+ tables = cursor.fetchall()
+
+ # Print a summary of each table
+ for table in tables:
+ table_name = table[0]
+ if table_name not in TABLE_NAMES:
+ continue
+ print(f"Table: {table_name}")
+
+ # Retrieve the table schema
+ cursor.execute(f"PRAGMA table_info({table_name})")
+ columns = cursor.fetchall()
+ column_names = [column[1] for column in columns]
+ print("- Columns:", column_names)
+
+ # Retrieve and print foreign key information
+ cursor.execute(f"PRAGMA foreign_key_list({table_name})")
+ foreign_keys = cursor.fetchall()
+ if foreign_keys:
+ print("- Foreign Keys:")
+ for fk in foreign_keys:
+ print(f" {fk[2]} references {fk[3]}({fk[4]}) on update "
+ f"{fk[5]} on delete {fk[6]}")
+ else:
+ print(" No foreign keys.")
+
+ # Print the first few rows of the table
+ cursor.execute(f"SELECT * FROM {table_name} LIMIT 5;")
+ rows = cursor.fetchall()
+ for row in rows:
+ print(row)
+ print() # Adds a newline for better readability between tables
+
+ # Close the database connection
+ conn.close()
+
+
+def fetch_table_from_db(cursor: sqlite3.Cursor,
+ table_name: str) -> List[Dict[str, Any]]:
+ cursor.execute(f"SELECT * FROM {table_name}")
+ columns = [description[0] for description in cursor.description]
+ data_dicts = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ return data_dicts
+
+
+def fetch_rec_table_as_matrix(cursor: sqlite3.Cursor) -> List[List[int]]:
+ # First, query all user_ids from the user table, assuming they start from
+ # 1 and are consecutive
+ cursor.execute("SELECT user_id FROM user ORDER BY user_id")
+ user_ids = [row[0] for row in cursor.fetchall()]
+
+ # Then, query all records from the rec table
+ cursor.execute(
+ "SELECT user_id, post_id FROM rec ORDER BY user_id, post_id")
+ rec_rows = cursor.fetchall()
+ # Initialize a dictionary, assigning an empty list to each user_id
+ user_posts = {user_id: [] for user_id in user_ids}
+ # Fill the dictionary with the records queried from the rec table
+ for user_id, post_id in rec_rows:
+ if user_id in user_posts:
+ user_posts[user_id].append(post_id)
+ # Convert the dictionary into matrix form
+ matrix = [user_posts[user_id] for user_id in user_ids]
+ return matrix
+
+
+def insert_matrix_into_rec_table(cursor: sqlite3.Cursor,
+ matrix: List[List[int]]) -> None:
+ # Iterate through the matrix, skipping the placeholder at index 0
+ for user_id, post_ids in enumerate(matrix, start=1):
+ # Adjusted to start counting from 1
+ for post_id in post_ids:
+ # Insert each combination of user_id and post_id into the rec table
+ cursor.execute("INSERT INTO rec (user_id, post_id) VALUES (?, ?)",
+ (user_id, post_id))
+
+
+if __name__ == "__main__":
+ create_db()
+ print_db_tables_summary()
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/platform.py b/backend/vendor/camel-oasis/oasis/social_platform/platform.py
new file mode 100644
index 00000000..672e4bbb
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/platform.py
@@ -0,0 +1,1642 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import random
+import sqlite3
+import sys
+from datetime import datetime, timedelta
+from typing import Any
+
+from oasis.clock.clock import Clock
+from oasis.social_platform.channel import Channel
+from oasis.social_platform.database import (create_db,
+ fetch_rec_table_as_matrix,
+ fetch_table_from_db)
+from oasis.social_platform.platform_utils import PlatformUtils
+from oasis.social_platform.recsys import (rec_sys_personalized_twh,
+ rec_sys_personalized_with_trace,
+ rec_sys_random, rec_sys_reddit)
+from oasis.social_platform.typing import ActionType, RecsysType
+
+# Create log directory if it doesn't exist
+log_dir = "./log"
+if not os.path.exists(log_dir):
+ os.makedirs(log_dir)
+
+if "sphinx" not in sys.modules:
+ twitter_log = logging.getLogger(name="social.twitter")
+ twitter_log.setLevel("DEBUG")
+ now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ file_handler = logging.FileHandler(f"./log/social.twitter-{now}.log")
+ file_handler.setLevel("DEBUG")
+ file_handler.setFormatter(
+ logging.Formatter(
+ "%(levelname)s - %(asctime)s - %(name)s - %(message)s"))
+ twitter_log.addHandler(file_handler)
+
+
+class Platform:
+ r"""Platform."""
+
+ def __init__(
+ self,
+ db_path: str,
+ channel: Any = None,
+ sandbox_clock: Clock | None = None,
+ start_time: datetime | None = None,
+ show_score: bool = False,
+ allow_self_rating: bool = True,
+ recsys_type: str | RecsysType = "reddit",
+ refresh_rec_post_count: int = 1,
+ max_rec_post_len: int = 2,
+ following_post_count=3,
+ use_openai_embedding: bool = False,
+ ):
+ self.db_path = db_path
+ self.recsys_type = recsys_type
+ # import pdb; pdb.set_trace()
+
+ # If no clock is specified, default the platform's time
+ # magnification factor to 60
+ if sandbox_clock is None:
+ sandbox_clock = Clock(60)
+ if start_time is None:
+ start_time = datetime.now()
+ self.start_time = start_time
+ self.sandbox_clock = sandbox_clock
+
+ self.db, self.db_cursor = create_db(self.db_path)
+ self.db.execute("PRAGMA synchronous = OFF")
+
+ self.channel = channel or Channel()
+
+ self.recsys_type = RecsysType(recsys_type)
+
+ # Whether to simulate showing scores like Reddit (likes minus dislikes)
+ # instead of showing likes and dislikes separately
+ self.show_score = show_score
+
+ # Whether to allow users to like or dislike their own posts and
+ # comments
+ self.allow_self_rating = allow_self_rating
+
+ # The number of posts returned by the social media internal
+ # recommendation system per refresh
+ self.refresh_rec_post_count = refresh_rec_post_count
+ # The number of posts returned at once from posts made by followed
+ # users, ranked by like counts
+ self.following_post_count = following_post_count
+ # The maximum number of posts per user in the recommendation
+ # table (buffer)
+ self.max_rec_post_len = max_rec_post_len
+ # rec prob between random and personalized
+ self.rec_prob = 0.7
+ self.use_openai_embedding = use_openai_embedding
+
+ # Parameters for the platform's internal trending rules
+ self.trend_num_days = 7
+ self.trend_top_k = 1
+
+ # Report threshold setting
+ self.report_threshold = 2
+
+ self.pl_utils = PlatformUtils(
+ self.db,
+ self.db_cursor,
+ self.start_time,
+ self.sandbox_clock,
+ self.show_score,
+ self.recsys_type,
+ self.report_threshold,
+ )
+
+ async def running(self):
+ while True:
+ message_id, data = await self.channel.receive_from()
+
+ agent_id, message, action = data
+ action = ActionType(action)
+
+ if action == ActionType.EXIT:
+ # If the database is in-memory, save it to a file before
+ # losing
+ if self.db_path == ":memory:":
+ dst = sqlite3.connect("mock.db")
+ with dst:
+ self.db.backup(dst)
+
+ self.db_cursor.close()
+ self.db.close()
+ break
+
+ # Retrieve the corresponding function using getattr
+ action_function = getattr(self, action.value, None)
+ if action_function:
+ # Get the names of the parameters of the function
+ func_code = action_function.__code__
+ param_names = func_code.co_varnames[:func_code.co_argcount]
+
+ len_param_names = len(param_names)
+ if len_param_names > 3:
+ raise ValueError(
+ f"Functions with {len_param_names} parameters are not "
+ f"supported.")
+ # Build a dictionary of parameters
+ params = {}
+ if len_param_names >= 2:
+ params["agent_id"] = agent_id
+ if len_param_names == 3:
+ # Assuming the second element in param_names is the name
+ # of the second parameter you want to add
+ second_param_name = param_names[2]
+ params[second_param_name] = message
+
+ # Call the function with the parameters
+ result = await action_function(**params)
+ await self.channel.send_to((message_id, agent_id, result))
+ else:
+ raise ValueError(f"Action {action} is not supported")
+
+ def run(self):
+ asyncio.run(self.running())
+
+ async def sign_up(self, agent_id, user_message):
+ user_name, name, bio = user_message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_insert_query = (
+ "INSERT INTO user (user_id, agent_id, user_name, name, "
+ "bio, created_at, num_followings, num_followers) VALUES "
+ "(?, ?, ?, ?, ?, ?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ user_insert_query,
+ (agent_id, agent_id, user_name, name, bio, current_time, 0, 0),
+ commit=True,
+ )
+ user_id = agent_id
+
+ action_info = {"name": name, "user_name": user_name, "bio": bio}
+ self.pl_utils._record_trace(user_id, ActionType.SIGNUP.value,
+ action_info, current_time)
+ # twitter_log.info(f"Trace inserted: user_id={user_id}, "
+ # f"current_time={current_time}, "
+ # f"action={ActionType.SIGNUP.value}, "
+ # f"info={action_info}")
+ return {"success": True, "user_id": user_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def sign_up_product(self, product_id: int, product_name: str):
+ # Note: do not sign up the product with the same product name
+ try:
+ product_insert_query = (
+ "INSERT INTO product (product_id, product_name) VALUES (?, ?)")
+ self.pl_utils._execute_db_command(product_insert_query,
+ (product_id, product_name),
+ commit=True)
+ return {"success": True, "product_id": product_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def purchase_product(self, agent_id, purchase_message):
+ product_name, purchase_num = purchase_message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ # try:
+ user_id = agent_id
+ # Check if a like record already exists
+ product_check_query = (
+ "SELECT * FROM 'product' WHERE product_name = ?")
+ self.pl_utils._execute_db_command(product_check_query,
+ (product_name, ))
+ check_result = self.db_cursor.fetchone()
+ if not check_result:
+ # Product not found
+ return {"success": False, "error": "No such product."}
+ else:
+ product_id = check_result[0]
+
+ product_update_query = (
+ "UPDATE product SET sales = sales + ? WHERE product_name = ?")
+ self.pl_utils._execute_db_command(product_update_query,
+ (purchase_num, product_name),
+ commit=True)
+
+ # Record the action in the trace table
+ action_info = {
+ "product_name": product_name,
+ "purchase_num": purchase_num
+ }
+ self.pl_utils._record_trace(user_id, ActionType.PURCHASE_PRODUCT.value,
+ action_info, current_time)
+ return {"success": True, "product_id": product_id}
+ # except Exception as e:
+ # return {"success": False, "error": str(e)}
+
+ async def refresh(self, agent_id: int):
+ # Retrieve posts for a specific id from the rec table
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ # Retrieve all post_ids for a given user_id from the rec table
+ rec_query = "SELECT post_id FROM rec WHERE user_id = ?"
+ self.pl_utils._execute_db_command(rec_query, (user_id, ))
+ rec_results = self.db_cursor.fetchall()
+
+ post_ids = [row[0] for row in rec_results]
+ selected_post_ids = post_ids
+ # If the number of post_ids >= self.refresh_rec_post_count,
+ # randomly select a specified number of post_ids
+ if len(selected_post_ids) >= self.refresh_rec_post_count:
+ selected_post_ids = random.sample(selected_post_ids,
+ self.refresh_rec_post_count)
+
+ if self.recsys_type != RecsysType.REDDIT:
+ # Retrieve posts from following (in network)
+ # Modify the SQL query so that the refresh gets posts from
+ # people the user follows, sorted by the number of likes on
+ # Twitter
+ query_following_post = (
+ "SELECT post.post_id, post.user_id, post.content, "
+ "post.created_at, post.num_likes FROM post "
+ "JOIN follow ON post.user_id = follow.followee_id "
+ "WHERE follow.follower_id = ? "
+ "ORDER BY post.num_likes DESC "
+ "LIMIT ?")
+ self.pl_utils._execute_db_command(
+ query_following_post,
+ (
+ user_id,
+ self.following_post_count,
+ ),
+ )
+
+ following_posts = self.db_cursor.fetchall()
+ following_posts_ids = [row[0] for row in following_posts]
+
+ selected_post_ids = following_posts_ids + selected_post_ids
+ selected_post_ids = list(set(selected_post_ids))
+
+ placeholders = ", ".join("?" for _ in selected_post_ids)
+
+ post_query = (
+ f"SELECT post_id, user_id, original_post_id, content, "
+ f"quote_content, created_at, num_likes, num_dislikes, "
+ f"num_shares FROM post WHERE post_id IN ({placeholders})")
+ self.pl_utils._execute_db_command(post_query, selected_post_ids)
+ results = self.db_cursor.fetchall()
+ if not results:
+ return {"success": False, "message": "No posts found."}
+ results_with_comments = self.pl_utils._add_comments_to_posts(
+ results)
+
+ action_info = {"posts": results_with_comments}
+ # twitter_log.info(action_info)
+ self.pl_utils._record_trace(user_id, ActionType.REFRESH.value,
+ action_info, current_time)
+
+ return {"success": True, "posts": results_with_comments}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def update_rec_table(self):
+ # Recsys(trace/user/post table), refresh rec table
+ twitter_log.info("Starting to refresh recommendation system cache...")
+ user_table = fetch_table_from_db(self.db_cursor, "user")
+ post_table = fetch_table_from_db(self.db_cursor, "post")
+ trace_table = fetch_table_from_db(self.db_cursor, "trace")
+ rec_matrix = fetch_rec_table_as_matrix(self.db_cursor)
+
+ if self.recsys_type == RecsysType.RANDOM:
+ new_rec_matrix = rec_sys_random(post_table, rec_matrix,
+ self.max_rec_post_len)
+ elif self.recsys_type == RecsysType.TWITTER:
+ new_rec_matrix = rec_sys_personalized_with_trace(
+ user_table, post_table, trace_table, rec_matrix,
+ self.max_rec_post_len)
+ elif self.recsys_type == RecsysType.TWHIN:
+ try:
+ latest_post_time = post_table[-1]["created_at"]
+ second_latest_post_time = post_table[-2]["created_at"] if len(
+ post_table) > 1 else latest_post_time
+ post_query = """
+ SELECT COUNT(*)
+ FROM post
+ WHERE created_at = ? OR created_at = ?
+ """
+ self.pl_utils._execute_db_command(
+ post_query, (latest_post_time, second_latest_post_time))
+ result = self.db_cursor.fetchone()
+ latest_post_count = result[0]
+ if not latest_post_count:
+ return {
+ "success": False,
+ "message": "Fail to get latest posts count"
+ }
+ new_rec_matrix = rec_sys_personalized_twh(
+ user_table,
+ post_table,
+ latest_post_count,
+ trace_table,
+ rec_matrix,
+ self.max_rec_post_len,
+ self.sandbox_clock.time_step,
+ use_openai_embedding=self.use_openai_embedding,
+ )
+ except Exception as e:
+ twitter_log.error(e)
+ # If no post in the platform, skip updating the rec table
+ return
+ elif self.recsys_type == RecsysType.REDDIT:
+ new_rec_matrix = rec_sys_reddit(post_table, rec_matrix,
+ self.max_rec_post_len)
+ else:
+ raise ValueError("Unsupported recommendation system type, please "
+ "check the `RecsysType`.")
+
+ sql_query = "DELETE FROM rec"
+ # Execute the SQL statement using the _execute_db_command function
+ self.pl_utils._execute_db_command(sql_query, commit=True)
+
+ # Batch insertion is more time-efficient
+ # create a list of values to insert
+ insert_values = [(user_id, post_id)
+ for user_id in range(len(new_rec_matrix))
+ for post_id in new_rec_matrix[user_id]]
+
+ # Perform batch insertion into the database
+ self.pl_utils._execute_many_db_command(
+ "INSERT INTO rec (user_id, post_id) VALUES (?, ?)",
+ insert_values,
+ commit=True,
+ )
+
+ async def create_post(self, agent_id: int, content: str):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ post_insert_query = (
+ "INSERT INTO post (user_id, content, created_at, num_likes, "
+ "num_dislikes, num_shares) VALUES (?, ?, ?, ?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ post_insert_query, (user_id, content, current_time, 0, 0, 0),
+ commit=True)
+ post_id = self.db_cursor.lastrowid
+
+ action_info = {"content": content, "post_id": post_id}
+ self.pl_utils._record_trace(user_id, ActionType.CREATE_POST.value,
+ action_info, current_time)
+
+ # twitter_log.info(f"Trace inserted: user_id={user_id}, "
+ # f"current_time={current_time}, "
+ # f"action={ActionType.CREATE_POST.value}, "
+ # f"info={action_info}")
+ return {"success": True, "post_id": post_id}
+
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def repost(self, agent_id: int, post_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Ensure the content has not been reposted by this user before
+ repost_check_query = (
+ "SELECT * FROM 'post' WHERE original_post_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(repost_check_query,
+ (post_id, user_id))
+ if self.db_cursor.fetchone():
+ # for common and quote post, check if the post has been
+ # reposted
+ return {
+ "success": False,
+ "error": "Repost record already exists."
+ }
+
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ post_insert_query = ("INSERT INTO post (user_id, original_post_id"
+ ", created_at) VALUES (?, ?, ?)")
+ # Update num_shares for the found post
+ update_shares_query = (
+ "UPDATE post SET num_shares = num_shares + 1 WHERE post_id = ?"
+ )
+
+ if not post_type_result:
+ return {"success": False, "error": "Post not found."}
+ elif (post_type_result['type'] == 'common'
+ or post_type_result['type'] == 'quote'):
+ self.pl_utils._execute_db_command(
+ post_insert_query, (user_id, post_id, current_time),
+ commit=True)
+ self.pl_utils._execute_db_command(update_shares_query,
+ (post_id, ),
+ commit=True)
+ elif post_type_result['type'] == 'repost':
+ repost_check_query = (
+ "SELECT * FROM 'post' WHERE original_post_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(
+ repost_check_query,
+ (post_type_result['root_post_id'], user_id))
+
+ if self.db_cursor.fetchone():
+ # for repost post, check if the post has been reposted
+ return {
+ "success": False,
+ "error": "Repost record already exists."
+ }
+
+ self.pl_utils._execute_db_command(
+ post_insert_query,
+ (user_id, post_type_result['root_post_id'], current_time),
+ commit=True)
+ self.pl_utils._execute_db_command(
+ update_shares_query, (post_type_result['root_post_id'], ),
+ commit=True)
+
+ new_post_id = self.db_cursor.lastrowid
+
+ action_info = {"reposted_id": post_id, "new_post_id": new_post_id}
+ self.pl_utils._record_trace(user_id, ActionType.REPOST.value,
+ action_info, current_time)
+
+ return {"success": True, "post_id": new_post_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def quote_post(self, agent_id: int, quote_message: tuple):
+ post_id, quote_content = quote_message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Allow quote a post more than once because the quote content may
+ # be different
+
+ post_query = "SELECT content FROM post WHERE post_id = ?"
+
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ post_insert_query = (
+ "INSERT INTO post (user_id, original_post_id, "
+ "content, quote_content, created_at) VALUES (?, ?, ?, ?, ?)")
+ update_shares_query = (
+ "UPDATE post SET num_shares = num_shares + 1 WHERE post_id = ?"
+ )
+
+ if not post_type_result:
+ return {"success": False, "error": "Post not found."}
+ elif post_type_result['type'] == 'common':
+ self.pl_utils._execute_db_command(post_query, (post_id, ))
+ post_content = self.db_cursor.fetchone()[0]
+ self.pl_utils._execute_db_command(
+ post_insert_query, (user_id, post_id, post_content,
+ quote_content, current_time),
+ commit=True)
+ self.pl_utils._execute_db_command(update_shares_query,
+ (post_id, ),
+ commit=True)
+ elif (post_type_result['type'] == 'repost'
+ or post_type_result['type'] == 'quote'):
+ self.pl_utils._execute_db_command(
+ post_query, (post_type_result['root_post_id'], ))
+ post_content = self.db_cursor.fetchone()[0]
+ self.pl_utils._execute_db_command(
+ post_insert_query,
+ (user_id, post_type_result['root_post_id'], post_content,
+ quote_content, current_time),
+ commit=True)
+ self.pl_utils._execute_db_command(
+ update_shares_query, (post_type_result['root_post_id'], ),
+ commit=True)
+
+ new_post_id = self.db_cursor.lastrowid
+
+ action_info = {"quoted_id": post_id, "new_post_id": new_post_id}
+ self.pl_utils._record_trace(user_id, ActionType.QUOTE_POST.value,
+ action_info, current_time)
+
+ return {"success": True, "post_id": new_post_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def like_post(self, agent_id: int, post_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ if post_type_result['type'] == 'repost':
+ post_id = post_type_result['root_post_id']
+ user_id = agent_id
+ # Check if a like record already exists
+ like_check_query = ("SELECT * FROM 'like' WHERE post_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (post_id, user_id))
+ if self.db_cursor.fetchone():
+ # Like record already exists
+ return {
+ "success": False,
+ "error": "Like record already exists."
+ }
+
+ # Check if the post to be liked is self-posted
+ if self.allow_self_rating is False:
+ check_result = self.pl_utils._check_self_post_rating(
+ post_id, user_id)
+ if check_result:
+ return check_result
+
+ # Update the number of likes in the post table
+ post_update_query = (
+ "UPDATE post SET num_likes = num_likes + 1 WHERE post_id = ?")
+ self.pl_utils._execute_db_command(post_update_query, (post_id, ),
+ commit=True)
+
+ # Add a record in the like table
+ like_insert_query = (
+ "INSERT INTO 'like' (post_id, user_id, created_at) "
+ "VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(like_insert_query,
+ (post_id, user_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted like record
+ like_id = self.db_cursor.lastrowid
+
+ # Record the action in the trace table
+ # if post has been reposted, record the root post id into trace
+ action_info = {"post_id": post_id, "like_id": like_id}
+ self.pl_utils._record_trace(user_id, ActionType.LIKE_POST.value,
+ action_info, current_time)
+ return {"success": True, "like_id": like_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def unlike_post(self, agent_id: int, post_id: int):
+ try:
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ if post_type_result['type'] == 'repost':
+ post_id = post_type_result['root_post_id']
+ user_id = agent_id
+
+ # Check if a like record already exists
+ like_check_query = ("SELECT * FROM 'like' WHERE post_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (post_id, user_id))
+ result = self.db_cursor.fetchone()
+
+ if not result:
+ # No like record exists
+ return {
+ "success": False,
+ "error": "Like record does not exist."
+ }
+
+ # Get the `like_id`
+ like_id, _, _, _ = result
+
+ # Update the number of likes in the post table
+ post_update_query = (
+ "UPDATE post SET num_likes = num_likes - 1 WHERE post_id = ?")
+ self.pl_utils._execute_db_command(
+ post_update_query,
+ (post_id, ),
+ commit=True,
+ )
+
+ # Delete the record in the like table
+ like_delete_query = "DELETE FROM 'like' WHERE like_id = ?"
+ self.pl_utils._execute_db_command(
+ like_delete_query,
+ (like_id, ),
+ commit=True,
+ )
+
+ # Record the action in the trace table
+ action_info = {"post_id": post_id, "like_id": like_id}
+ self.pl_utils._record_trace(user_id, ActionType.UNLIKE_POST.value,
+ action_info)
+ return {"success": True, "like_id": like_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def dislike_post(self, agent_id: int, post_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ if post_type_result['type'] == 'repost':
+ post_id = post_type_result['root_post_id']
+ user_id = agent_id
+ # Check if a dislike record already exists
+ like_check_query = (
+ "SELECT * FROM 'dislike' WHERE post_id = ? AND user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (post_id, user_id))
+ if self.db_cursor.fetchone():
+ # Dislike record already exists
+ return {
+ "success": False,
+ "error": "Dislike record already exists."
+ }
+
+ # Check if the post to be disliked is self-posted
+ if self.allow_self_rating is False:
+ check_result = self.pl_utils._check_self_post_rating(
+ post_id, user_id)
+ if check_result:
+ return check_result
+
+ # Update the number of dislikes in the post table
+ post_update_query = (
+ "UPDATE post SET num_dislikes = num_dislikes + 1 WHERE "
+ "post_id = ?")
+ self.pl_utils._execute_db_command(post_update_query, (post_id, ),
+ commit=True)
+
+ # Add a record in the dislike table
+ dislike_insert_query = (
+ "INSERT INTO 'dislike' (post_id, user_id, created_at) "
+ "VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(dislike_insert_query,
+ (post_id, user_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted dislike record
+ dislike_id = self.db_cursor.lastrowid
+
+ # Record the action in the trace table
+ action_info = {"post_id": post_id, "dislike_id": dislike_id}
+ self.pl_utils._record_trace(user_id, ActionType.DISLIKE_POST.value,
+ action_info, current_time)
+ return {"success": True, "dislike_id": dislike_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def undo_dislike_post(self, agent_id: int, post_id: int):
+ try:
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ if post_type_result['type'] == 'repost':
+ post_id = post_type_result['root_post_id']
+ user_id = agent_id
+
+ # Check if a dislike record already exists
+ like_check_query = (
+ "SELECT * FROM 'dislike' WHERE post_id = ? AND user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (post_id, user_id))
+ result = self.db_cursor.fetchone()
+
+ if not result:
+ # No dislike record exists
+ return {
+ "success": False,
+ "error": "Dislike record does not exist."
+ }
+
+ # Get the `dislike_id`
+ dislike_id, _, _, _ = result
+
+ # Update the number of dislikes in the post table
+ post_update_query = (
+ "UPDATE post SET num_dislikes = num_dislikes - 1 WHERE "
+ "post_id = ?")
+ self.pl_utils._execute_db_command(
+ post_update_query,
+ (post_id, ),
+ commit=True,
+ )
+
+ # Delete the record in the dislike table
+ like_delete_query = "DELETE FROM 'dislike' WHERE dislike_id = ?"
+ self.pl_utils._execute_db_command(
+ like_delete_query,
+ (dislike_id, ),
+ commit=True,
+ )
+
+ # Record the action in the trace table
+ action_info = {"post_id": post_id, "dislike_id": dislike_id}
+ self.pl_utils._record_trace(user_id,
+ ActionType.UNDO_DISLIKE_POST.value,
+ action_info)
+ return {"success": True, "dislike_id": dislike_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def search_posts(self, agent_id: int, query: str):
+ try:
+ user_id = agent_id
+ # Update the SQL query to search by content, post_id, and user_id
+ # simultaneously
+ sql_query = (
+ "SELECT post_id, user_id, original_post_id, content, "
+ "quote_content, created_at, num_likes, num_dislikes, "
+ "num_shares FROM post WHERE content LIKE ? OR CAST(post_id AS "
+ "TEXT) LIKE ? OR CAST(user_id AS TEXT) LIKE ?")
+ # Note: CAST is necessary because post_id and user_id are integers,
+ # while the search query is a string type
+ self.pl_utils._execute_db_command(
+ sql_query,
+ ("%" + query + "%", "%" + query + "%", "%" + query + "%"),
+ commit=True,
+ )
+ results = self.db_cursor.fetchall()
+
+ # Record the operation in the trace table
+ action_info = {"query": query}
+ self.pl_utils._record_trace(user_id, ActionType.SEARCH_POSTS.value,
+ action_info)
+
+ # If no results are found, return a dictionary indicating failure
+ if not results:
+ return {
+ "success": False,
+ "message": "No posts found matching the query.",
+ }
+ results_with_comments = self.pl_utils._add_comments_to_posts(
+ results)
+
+ return {"success": True, "posts": results_with_comments}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def search_user(self, agent_id: int, query: str):
+ try:
+ user_id = agent_id
+ sql_query = (
+ "SELECT user_id, user_name, name, bio, created_at, "
+ "num_followings, num_followers "
+ "FROM user "
+ "WHERE user_name LIKE ? OR name LIKE ? OR bio LIKE ? OR "
+ "CAST(user_id AS TEXT) LIKE ?")
+ # Rewrite to use the execute_db_command method
+ self.pl_utils._execute_db_command(
+ sql_query,
+ (
+ "%" + query + "%",
+ "%" + query + "%",
+ "%" + query + "%",
+ "%" + query + "%",
+ ),
+ commit=True,
+ )
+ results = self.db_cursor.fetchall()
+
+ # Record the operation in the trace table
+ action_info = {"query": query}
+ self.pl_utils._record_trace(user_id, ActionType.SEARCH_USER.value,
+ action_info)
+
+ # If no results are found, return a dict indicating failure
+ if not results:
+ return {
+ "success": False,
+ "message": "No users found matching the query.",
+ }
+
+ # Convert each tuple in results into a dictionary
+ users = [{
+ "user_id": user_id,
+ "user_name": user_name,
+ "name": name,
+ "bio": bio,
+ "created_at": created_at,
+ "num_followings": num_followings,
+ "num_followers": num_followers,
+ } for user_id, user_name, name, bio, created_at, num_followings,
+ num_followers in results]
+ return {"success": True, "users": users}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def follow(self, agent_id: int, followee_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ # Check if a follow record already exists
+ follow_check_query = ("SELECT * FROM follow WHERE follower_id = ? "
+ "AND followee_id = ?")
+ self.pl_utils._execute_db_command(follow_check_query,
+ (user_id, followee_id))
+ if self.db_cursor.fetchone():
+ # Follow record already exists
+ return {
+ "success": False,
+ "error": "Follow record already exists."
+ }
+
+ # Add a record in the follow table
+ follow_insert_query = (
+ "INSERT INTO follow (follower_id, followee_id, created_at) "
+ "VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ follow_insert_query, (user_id, followee_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted follow record
+ follow_id = self.db_cursor.lastrowid
+
+ # Update the following field in the user table
+ user_update_query1 = (
+ "UPDATE user SET num_followings = num_followings + 1 "
+ "WHERE user_id = ?")
+ self.pl_utils._execute_db_command(user_update_query1, (user_id, ),
+ commit=True)
+
+ # Update the follower field in the user table
+ user_update_query2 = (
+ "UPDATE user SET num_followers = num_followers + 1 "
+ "WHERE user_id = ?")
+ self.pl_utils._execute_db_command(user_update_query2,
+ (followee_id, ),
+ commit=True)
+
+ # Record the operation in the trace table
+ action_info = {"follow_id": follow_id}
+ self.pl_utils._record_trace(user_id, ActionType.FOLLOW.value,
+ action_info, current_time)
+ # twitter_log.info(f"Trace inserted: user_id={user_id}, "
+ # f"current_time={current_time}, "
+ # f"action={ActionType.FOLLOW.value}, "
+ # f"info={action_info}")
+ return {"success": True, "follow_id": follow_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def unfollow(self, agent_id: int, followee_id: int):
+ try:
+ user_id = agent_id
+ # Check for the existence of a follow record and get its ID
+ follow_check_query = (
+ "SELECT follow_id FROM follow WHERE follower_id = ? AND "
+ "followee_id = ?")
+ self.pl_utils._execute_db_command(follow_check_query,
+ (user_id, followee_id))
+ follow_record = self.db_cursor.fetchone()
+ if not follow_record:
+ return {
+ "success": False,
+ "error": "Follow record does not exist."
+ }
+ # Assuming ID is in the first column of the result
+ follow_id = follow_record[0]
+
+ # Delete the record in the follow table
+ follow_delete_query = "DELETE FROM follow WHERE follow_id = ?"
+ self.pl_utils._execute_db_command(follow_delete_query,
+ (follow_id, ),
+ commit=True)
+
+ # Update the following field in the user table
+ user_update_query1 = (
+ "UPDATE user SET num_followings = num_followings - 1 "
+ "WHERE user_id = ?")
+ self.pl_utils._execute_db_command(user_update_query1, (user_id, ),
+ commit=True)
+
+ # Update the follower field in the user table
+ user_update_query2 = (
+ "UPDATE user SET num_followers = num_followers - 1 "
+ "WHERE user_id = ?")
+ self.pl_utils._execute_db_command(user_update_query2,
+ (followee_id, ),
+ commit=True)
+
+ # Record the operation in the trace table
+ action_info = {"followee_id": followee_id}
+ self.pl_utils._record_trace(user_id, ActionType.UNFOLLOW.value,
+ action_info)
+ return {
+ "success": True,
+ "follow_id": follow_id,
+ } # Return the ID of the deleted follow record
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def mute(self, agent_id: int, mutee_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ # Check if a mute record already exists
+ mute_check_query = ("SELECT * FROM mute WHERE muter_id = ? AND "
+ "mutee_id = ?")
+ self.pl_utils._execute_db_command(mute_check_query,
+ (user_id, mutee_id))
+ if self.db_cursor.fetchone():
+ # Mute record already exists
+ return {
+ "success": False,
+ "error": "Mute record already exists."
+ }
+ # Add a record in the mute table
+ mute_insert_query = (
+ "INSERT INTO mute (muter_id, mutee_id, created_at) "
+ "VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ mute_insert_query, (user_id, mutee_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted mute record
+ mute_id = self.db_cursor.lastrowid
+
+ # Record the operation in the trace table
+ action_info = {"mutee_id": mutee_id}
+ self.pl_utils._record_trace(user_id, ActionType.MUTE.value,
+ action_info, current_time)
+ return {"success": True, "mute_id": mute_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def unmute(self, agent_id: int, mutee_id: int):
+ try:
+ user_id = agent_id
+ # Check for the specified mute record and get mute_id
+ mute_check_query = (
+ "SELECT mute_id FROM mute WHERE muter_id = ? AND mutee_id = ?")
+ self.pl_utils._execute_db_command(mute_check_query,
+ (user_id, mutee_id))
+ mute_record = self.db_cursor.fetchone()
+ if not mute_record:
+ # If no mute record exists
+ return {"success": False, "error": "No mute record exists."}
+ mute_id = mute_record[0]
+
+ # Delete the specified mute record from the mute table
+ mute_delete_query = "DELETE FROM mute WHERE mute_id = ?"
+ self.pl_utils._execute_db_command(mute_delete_query, (mute_id, ),
+ commit=True)
+
+ # Record the unmute operation in the trace table
+ action_info = {"mutee_id": mutee_id}
+ self.pl_utils._record_trace(user_id, ActionType.UNMUTE.value,
+ action_info)
+ return {"success": True, "mute_id": mute_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def trend(self, agent_id: int):
+ """
+ Get the top K trending posts in the last num_days days.
+ """
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ # Calculate the start time for the search
+ if self.recsys_type == RecsysType.REDDIT:
+ start_time = current_time - timedelta(days=self.trend_num_days)
+ else:
+ start_time = int(current_time) - self.trend_num_days * 24 * 60
+
+ # Build the SQL query
+ sql_query = """
+ SELECT post_id, user_id, original_post_id, content,
+ quote_content, created_at, num_likes, num_dislikes,
+ num_shares FROM post
+ WHERE created_at >= ?
+ ORDER BY num_likes DESC
+ LIMIT ?
+ """
+ # Execute the database query
+ self.pl_utils._execute_db_command(sql_query,
+ (start_time, self.trend_top_k),
+ commit=True)
+ results = self.db_cursor.fetchall()
+
+ # If no results were found, return a dictionary indicating failure
+ if not results:
+ return {
+ "success": False,
+ "message": "No trending posts in the specified period.",
+ }
+ results_with_comments = self.pl_utils._add_comments_to_posts(
+ results)
+
+ action_info = {"posts": results_with_comments}
+ self.pl_utils._record_trace(user_id, ActionType.TREND.value,
+ action_info, current_time)
+
+ return {"success": True, "posts": results_with_comments}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def create_comment(self, agent_id: int, comment_message: tuple):
+ post_id, content = comment_message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ post_type_result = self.pl_utils._get_post_type(post_id)
+ if post_type_result['type'] == 'repost':
+ post_id = post_type_result['root_post_id']
+ user_id = agent_id
+
+ # Insert the comment record
+ comment_insert_query = (
+ "INSERT INTO comment (post_id, user_id, content, created_at) "
+ "VALUES (?, ?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ comment_insert_query,
+ (post_id, user_id, content, current_time),
+ commit=True,
+ )
+ comment_id = self.db_cursor.lastrowid
+
+ # Prepare information for the trace record
+ action_info = {"content": content, "comment_id": comment_id}
+ self.pl_utils._record_trace(user_id,
+ ActionType.CREATE_COMMENT.value,
+ action_info, current_time)
+
+ return {"success": True, "comment_id": comment_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def like_comment(self, agent_id: int, comment_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Check if a like record already exists
+ like_check_query = (
+ "SELECT * FROM comment_like WHERE comment_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (comment_id, user_id))
+ if self.db_cursor.fetchone():
+ # Like record already exists
+ return {
+ "success": False,
+ "error": "Comment like record already exists.",
+ }
+
+ # Check if the comment to be liked was posted by oneself
+ if self.allow_self_rating is False:
+ check_result = self.pl_utils._check_self_comment_rating(
+ comment_id, user_id)
+ if check_result:
+ return check_result
+
+ # Update the number of likes in the comment table
+ comment_update_query = (
+ "UPDATE comment SET num_likes = num_likes + 1 WHERE "
+ "comment_id = ?")
+ self.pl_utils._execute_db_command(comment_update_query,
+ (comment_id, ),
+ commit=True)
+
+ # Add a record in the comment_like table
+ like_insert_query = (
+ "INSERT INTO comment_like (comment_id, user_id, created_at) "
+ "VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ like_insert_query, (comment_id, user_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted like record
+ comment_like_id = self.db_cursor.lastrowid
+
+ # Record the operation in the trace table
+ action_info = {
+ "comment_id": comment_id,
+ "comment_like_id": comment_like_id
+ }
+ self.pl_utils._record_trace(user_id, ActionType.LIKE_COMMENT.value,
+ action_info, current_time)
+ return {"success": True, "comment_like_id": comment_like_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def unlike_comment(self, agent_id: int, comment_id: int):
+ try:
+ user_id = agent_id
+
+ # Check if a like record already exists
+ like_check_query = (
+ "SELECT * FROM comment_like WHERE comment_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(like_check_query,
+ (comment_id, user_id))
+ result = self.db_cursor.fetchone()
+
+ if not result:
+ # No like record exists
+ return {
+ "success": False,
+ "error": "Comment like record does not exist.",
+ }
+ # Get the `comment_like_id`
+ comment_like_id = result[0]
+
+ # Update the number of likes in the comment table
+ comment_update_query = (
+ "UPDATE comment SET num_likes = num_likes - 1 WHERE "
+ "comment_id = ?")
+ self.pl_utils._execute_db_command(
+ comment_update_query,
+ (comment_id, ),
+ commit=True,
+ )
+ # Delete the record in the comment_like table
+ like_delete_query = ("DELETE FROM comment_like WHERE "
+ "comment_like_id = ?")
+ self.pl_utils._execute_db_command(
+ like_delete_query,
+ (comment_like_id, ),
+ commit=True,
+ )
+ # Record the operation in the trace table
+ action_info = {
+ "comment_id": comment_id,
+ "comment_like_id": comment_like_id
+ }
+ self.pl_utils._record_trace(user_id,
+ ActionType.UNLIKE_COMMENT.value,
+ action_info)
+ return {"success": True, "comment_like_id": comment_like_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def dislike_comment(self, agent_id: int, comment_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Check if a dislike record already exists
+ dislike_check_query = (
+ "SELECT * FROM comment_dislike WHERE comment_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(dislike_check_query,
+ (comment_id, user_id))
+ if self.db_cursor.fetchone():
+ # Dislike record already exists
+ return {
+ "success": False,
+ "error": "Comment dislike record already exists.",
+ }
+
+ # Check if the comment to be disliked was posted by oneself
+ if self.allow_self_rating is False:
+ check_result = self.pl_utils._check_self_comment_rating(
+ comment_id, user_id)
+ if check_result:
+ return check_result
+
+ # Update the number of dislikes in the comment table
+ comment_update_query = (
+ "UPDATE comment SET num_dislikes = num_dislikes + 1 WHERE "
+ "comment_id = ?")
+ self.pl_utils._execute_db_command(comment_update_query,
+ (comment_id, ),
+ commit=True)
+
+ # Add a record in the comment_dislike table
+ dislike_insert_query = (
+ "INSERT INTO comment_dislike (comment_id, user_id, "
+ "created_at) VALUES (?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ dislike_insert_query, (comment_id, user_id, current_time),
+ commit=True)
+ # Get the ID of the newly inserted dislike record
+ comment_dislike_id = (self.db_cursor.lastrowid)
+
+ # Record the operation in the trace table
+ action_info = {
+ "comment_id": comment_id,
+ "comment_dislike_id": comment_dislike_id,
+ }
+ self.pl_utils._record_trace(user_id,
+ ActionType.DISLIKE_COMMENT.value,
+ action_info, current_time)
+ return {"success": True, "comment_dislike_id": comment_dislike_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def undo_dislike_comment(self, agent_id: int, comment_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Check if a dislike record already exists
+ dislike_check_query = (
+ "SELECT comment_dislike_id FROM comment_dislike WHERE "
+ "comment_id = ? AND user_id = ?")
+ self.pl_utils._execute_db_command(dislike_check_query,
+ (comment_id, user_id))
+ dislike_record = self.db_cursor.fetchone()
+ if not dislike_record:
+ # No dislike record exists
+ return {
+ "success": False,
+ "error": "Comment dislike record does not exist.",
+ }
+ comment_dislike_id = dislike_record[0]
+
+ # Delete the record from the comment_dislike table
+ dislike_delete_query = (
+ "DELETE FROM comment_dislike WHERE comment_id = ? AND "
+ "user_id = ?")
+ self.pl_utils._execute_db_command(dislike_delete_query,
+ (comment_id, user_id),
+ commit=True)
+
+ # Update the number of dislikes in the comment table
+ comment_update_query = (
+ "UPDATE comment SET num_dislikes = num_dislikes - 1 WHERE "
+ "comment_id = ?")
+ self.pl_utils._execute_db_command(comment_update_query,
+ (comment_id, ),
+ commit=True)
+
+ # Record the operation in the trace table
+ action_info = {
+ "comment_id": comment_id,
+ "comment_dislike_id": comment_dislike_id,
+ }
+ self.pl_utils._record_trace(user_id,
+ ActionType.UNDO_DISLIKE_COMMENT.value,
+ action_info, current_time)
+ return {"success": True, "comment_dislike_id": comment_dislike_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def do_nothing(self, agent_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ action_info = {}
+ self.pl_utils._record_trace(user_id, ActionType.DO_NOTHING.value,
+ action_info, current_time)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def interview(self, agent_id: int, interview_data):
+ """Interview an agent with the given prompt and record the response.
+
+ Args:
+ agent_id (int): The ID of the agent being interviewed.
+ interview_data: Either a string (prompt only) or dict with prompt
+ and response.
+
+ Returns:
+ dict: A dictionary with success status.
+ """
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # Handle both old format (string prompt) and new format
+ # (dict with prompt + response)
+ if isinstance(interview_data, str):
+ # Old format: just the prompt
+ prompt = interview_data
+ response = None
+ interview_id = f"{current_time}_{user_id}"
+ action_info = {"prompt": prompt, "interview_id": interview_id}
+ else:
+ # New format: dict with prompt and response
+ prompt = interview_data.get("prompt", "")
+ response = interview_data.get("response", "")
+ interview_id = f"{current_time}_{user_id}"
+ action_info = {
+ "prompt": prompt,
+ "response": response,
+ "interview_id": interview_id
+ }
+
+ # Record the interview in the trace table
+ self.pl_utils._record_trace(user_id, ActionType.INTERVIEW.value,
+ action_info, current_time)
+
+ return {"success": True, "interview_id": interview_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def report_post(self, agent_id: int, report_message: tuple):
+ post_id, report_reason = report_message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ post_type_result = self.pl_utils._get_post_type(post_id)
+
+ # Check if a report record already exists
+ check_report_query = (
+ "SELECT * FROM report WHERE user_id = ? AND post_id = ?")
+ self.pl_utils._execute_db_command(check_report_query,
+ (user_id, post_id))
+ if self.db_cursor.fetchone():
+ return {
+ "success": False,
+ "error": "Report record already exists."
+ }
+
+ if not post_type_result:
+ return {"success": False, "error": "Post not found."}
+
+ # Update the number of reports in the post table
+ update_reports_query = (
+ "UPDATE post SET num_reports = num_reports + 1 WHERE "
+ "post_id = ?")
+ self.pl_utils._execute_db_command(update_reports_query,
+ (post_id, ),
+ commit=True)
+
+ # Add a report in the report table
+ report_insert_query = (
+ "INSERT INTO report (post_id, user_id, report_reason, "
+ "created_at) VALUES (?, ?, ?, ?)")
+ self.pl_utils._execute_db_command(
+ report_insert_query,
+ (post_id, user_id, report_reason, current_time),
+ commit=True)
+
+ # Get the ID of the newly inserted report record
+ report_id = self.db_cursor.lastrowid
+
+ # Record the action in the trace table
+ action_info = {"post_id": post_id, "report_id": report_id}
+ self.pl_utils._record_trace(user_id, ActionType.REPORT_POST.value,
+ action_info, current_time)
+
+ return {"success": True, "report_id": report_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def send_to_group(self, agent_id: int, message: tuple):
+ group_id, content = message
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+ # check if user is a member of the group
+ check_query = ("SELECT * FROM group_members WHERE group_id = ? "
+ "AND agent_id = ?")
+ self.pl_utils._execute_db_command(check_query, (group_id, user_id))
+ if not self.db_cursor.fetchone():
+ return {
+ "success": False,
+ "error": "User is not a member of this group.",
+ }
+
+ # Insert the message into the group_messages table
+ insert_query = """
+ INSERT INTO group_messages
+ (group_id, sender_id, content, sent_at)
+ VALUES (?, ?, ?, ?)
+ """
+ self.pl_utils._execute_db_command(
+ insert_query, (group_id, user_id, content, current_time),
+ commit=True)
+ message_id = self.db_cursor.lastrowid
+
+ # get the group members
+ members_query = ("SELECT agent_id FROM group_members WHERE "
+ "group_id = ? AND agent_id != ?")
+ self.pl_utils._execute_db_command(members_query,
+ (group_id, user_id))
+ members = [row[0] for row in self.db_cursor.fetchall()]
+ action_info = {
+ "group_id": group_id,
+ "message_id": message_id,
+ "content": content,
+ }
+ self.pl_utils._record_trace(user_id,
+ ActionType.SEND_TO_GROUP.value,
+ action_info, current_time)
+
+ return {"success": True, "message_id": message_id, "to": members}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def create_group(self, agent_id: int, group_name: str):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # insert the group into the groups table
+ insert_query = """
+ INSERT INTO chat_group (name, created_at) VALUES (?, ?)
+ """
+ self.pl_utils._execute_db_command(insert_query,
+ (group_name, current_time),
+ commit=True)
+ group_id = self.db_cursor.lastrowid
+
+ # insert the user as a member of the group
+ join_query = """
+ INSERT INTO group_members (group_id, agent_id, joined_at)
+ VALUES (?, ?, ?)
+ """
+ self.pl_utils._execute_db_command(
+ join_query, (group_id, user_id, current_time), commit=True)
+
+ action_info = {"group_id": group_id, "group_name": group_name}
+ self.pl_utils._record_trace(user_id, ActionType.CREATE_GROUP.value,
+ action_info, current_time)
+
+ return {"success": True, "group_id": group_id}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def join_group(self, agent_id: int, group_id: int):
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+ try:
+ user_id = agent_id
+
+ # check if group exists
+ check_group_query = """SELECT * FROM chat_group
+ WHERE group_id = ?"""
+ self.pl_utils._execute_db_command(check_group_query, (group_id, ))
+ if not self.db_cursor.fetchone():
+ return {"success": False, "error": "Group does not exist."}
+
+ # check if user is already in the group
+ check_member_query = (
+ "SELECT * FROM group_members WHERE group_id = ? "
+ "AND agent_id = ?")
+ self.pl_utils._execute_db_command(check_member_query,
+ (group_id, user_id))
+ if self.db_cursor.fetchone():
+ return {
+ "success": False,
+ "error": "User is already in the group."
+ }
+
+ # join the group
+ join_query = """
+ INSERT INTO group_members
+ (group_id, agent_id, joined_at) VALUES (?, ?, ?)
+ """
+ self.pl_utils._execute_db_command(
+ join_query, (group_id, user_id, current_time), commit=True)
+
+ action_info = {"group_id": group_id}
+ self.pl_utils._record_trace(user_id, ActionType.JOIN_GROUP.value,
+ action_info, current_time)
+
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def leave_group(self, agent_id: int, group_id: int):
+ try:
+ user_id = agent_id
+
+ # check if user is a member of the group
+ check_query = ("SELECT * FROM group_members "
+ "WHERE group_id = ? AND agent_id = ?")
+ self.pl_utils._execute_db_command(check_query, (group_id, user_id))
+ if not self.db_cursor.fetchone():
+ return {
+ "success": False,
+ "error": "User is not a member of this group."
+ }
+
+ # delete the member record
+ delete_query = ("DELETE FROM group_members "
+ "WHERE group_id = ? AND agent_id = ?")
+ self.pl_utils._execute_db_command(delete_query,
+ (group_id, user_id),
+ commit=True)
+
+ action_info = {"group_id": group_id}
+ self.pl_utils._record_trace(user_id, ActionType.LEAVE_GROUP.value,
+ action_info)
+
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ async def listen_from_group(self, agent_id: int):
+ try:
+ # get all groups Dict[group_id, group_name]
+ query = """ SELECT * FROM chat_group """
+ self.pl_utils._execute_db_command(query)
+ all_groups = {}
+ for row in self.db_cursor.fetchall():
+ all_groups[row[0]] = row[1]
+
+ # get all groups that the user is a member of
+ in_query = """
+ SELECT group_id FROM group_members WHERE agent_id = ?
+ """
+ self.pl_utils._execute_db_command(in_query, (agent_id, ))
+ joined_group_ids = [row[0] for row in self.db_cursor.fetchall()]
+
+ # get all messages from those groups, Dict[group_id, [messages]]
+ messages = {}
+ for group_id in joined_group_ids:
+ select_query = """
+ SELECT message_id, content, sender_id,
+ sent_at FROM group_messages WHERE group_id = ?
+ """
+ self.pl_utils._execute_db_command(select_query, (group_id, ))
+ messages[group_id] = [{
+ "message_id": row[0],
+ "content": row[1],
+ "sender_id": row[2],
+ "sent_at": row[3],
+ } for row in self.db_cursor.fetchall()]
+
+ return {
+ "success": True,
+ "all_groups": all_groups,
+ "joined_groups": joined_group_ids,
+ "messages": messages
+ }
+ except Exception as e:
+ return {"success": False, "error": str(e)}
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/platform_utils.py b/backend/vendor/camel-oasis/oasis/social_platform/platform_utils.py
new file mode 100644
index 00000000..333a4bc7
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/platform_utils.py
@@ -0,0 +1,262 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+import json
+from datetime import datetime
+
+from oasis.social_platform.typing import RecsysType
+
+
+class PlatformUtils:
+
+ def __init__(self,
+ db,
+ db_cursor,
+ start_time,
+ sandbox_clock,
+ show_score,
+ recsys_type,
+ report_threshold=1):
+ self.db = db
+ self.db_cursor = db_cursor
+ self.start_time = start_time
+ self.sandbox_clock = sandbox_clock
+ self.show_score = show_score
+ self.recsys_type = recsys_type
+ self.report_threshold = report_threshold
+
+ @staticmethod
+ def _not_signup_error_message(agent_id):
+ return {
+ "success":
+ False,
+ "error": (f"Agent {agent_id} has not signed up and does not have "
+ f"a user id."),
+ }
+
+ def _execute_db_command(self, command, args=(), commit=False):
+ self.db_cursor.execute(command, args)
+ if commit:
+ self.db.commit()
+ return self.db_cursor
+
+ def _execute_many_db_command(self, command, args_list, commit=False):
+ self.db_cursor.executemany(command, args_list)
+ if commit:
+ self.db.commit()
+ return self.db_cursor
+
+ def _check_agent_userid(self, agent_id):
+ try:
+ user_query = "SELECT user_id FROM user WHERE agent_id = ?"
+ results = self._execute_db_command(user_query, (agent_id, ))
+ # Fetch the first row of the query result
+ first_row = results.fetchone()
+ if first_row:
+ user_id = first_row[0]
+ return user_id
+ else:
+ return None
+ except Exception as e:
+ # Log or handle the error as appropriate
+ print(f"Error querying user_id for agent_id {agent_id}: {e}")
+ return None
+
+ def _add_comments_to_posts(self, posts_results):
+ # Initialize the returned posts list
+ posts = []
+ for row in posts_results:
+ (post_id, user_id, original_post_id, content, quote_content,
+ created_at, num_likes, num_dislikes, num_shares) = row
+ post_type_result = self._get_post_type(post_id)
+ if post_type_result is None:
+ continue
+ original_user_id_query = (
+ "SELECT user_id FROM post WHERE post_id = ?")
+ if post_type_result["type"] == "repost":
+ self.db_cursor.execute(original_user_id_query,
+ (original_post_id, ))
+ original_user_id = self.db_cursor.fetchone()[0]
+ original_post_id = post_id
+ post_id = post_type_result["root_post_id"]
+ self.db_cursor.execute(
+ "SELECT content, quote_content, created_at, num_likes, "
+ "num_dislikes, num_shares, num_reports FROM post "
+ "WHERE post_id = ?", (post_id, ))
+ original_post_result = self.db_cursor.fetchone()
+ (content, quote_content, created_at, num_likes, num_dislikes,
+ num_shares, num_reports) = original_post_result
+ post_content = (
+ f"User {user_id} reposted a post from User "
+ f"{original_user_id}. Repost content: {content}. ")
+
+ elif post_type_result["type"] == "quote":
+ self.db_cursor.execute(original_user_id_query,
+ (original_post_id, ))
+ original_user_id = self.db_cursor.fetchone()[0]
+ post_content = (
+ f"User {user_id} quoted a post from User "
+ f"{original_user_id}. Quote content: {quote_content}. "
+ f"Original Content: {content}")
+
+ elif post_type_result["type"] == "common":
+ post_content = content
+ # Get num_reports for common posts
+ self.db_cursor.execute(
+ "SELECT num_reports FROM post WHERE post_id = ?",
+ (post_id, ))
+ num_reports = self.db_cursor.fetchone()[0]
+
+ # For each post, query its corresponding comments
+ self.db_cursor.execute(
+ "SELECT comment_id, post_id, user_id, content, created_at, "
+ "num_likes, num_dislikes FROM comment WHERE post_id = ?",
+ (post_id, ),
+ )
+ comments_results = self.db_cursor.fetchall()
+
+ # Convert each comment's result into dictionary format
+ comments = [{
+ "comment_id":
+ comment_id,
+ "post_id":
+ post_id,
+ "user_id":
+ user_id,
+ "content":
+ content,
+ "created_at":
+ created_at,
+ **({
+ "score": num_likes - num_dislikes
+ } if self.show_score else {
+ "num_likes": num_likes,
+ "num_dislikes": num_dislikes
+ }),
+ } for (
+ comment_id,
+ post_id,
+ user_id,
+ content,
+ created_at,
+ num_likes,
+ num_dislikes,
+ ) in comments_results]
+
+ # Add warning message if the post has been reported
+ if num_reports >= self.report_threshold:
+ warning_message = ("[Warning: This post has been reported"
+ f" {num_reports} times]")
+ post_content = f"{warning_message}\n{post_content}"
+
+ # Add post information and corresponding comments to the posts list
+ posts.append({
+ "post_id":
+ post_id
+ if post_type_result["type"] != "repost" else original_post_id,
+ "user_id":
+ user_id,
+ "content":
+ post_content,
+ "created_at":
+ created_at,
+ **({
+ "score": num_likes - num_dislikes
+ } if self.show_score else {
+ "num_likes": num_likes,
+ "num_dislikes": num_dislikes
+ }),
+ "num_shares":
+ num_shares,
+ "num_reports":
+ num_reports,
+ "comments":
+ comments,
+ })
+ return posts
+
+ def _record_trace(self,
+ user_id,
+ action_type,
+ action_info,
+ current_time=None):
+ r"""If, in addition to the trace, the operation function also records
+ time in other tables of the database, use the time of entering
+ the operation function for consistency.
+
+ Pass in current_time to make, for example, the created_at in the post
+ table exactly the same as the time in the trace table.
+
+ If only the trace table needs to record time, use the entry time into
+ _record_trace as the time for the trace record.
+ """
+ if self.recsys_type == RecsysType.REDDIT:
+ current_time = self.sandbox_clock.time_transfer(
+ datetime.now(), self.start_time)
+ else:
+ current_time = self.sandbox_clock.get_time_step()
+
+ trace_insert_query = (
+ "INSERT INTO trace (user_id, created_at, action, info) "
+ "VALUES (?, ?, ?, ?)")
+ action_info_str = json.dumps(action_info)
+ self._execute_db_command(
+ trace_insert_query,
+ (user_id, current_time, action_type, action_info_str),
+ commit=True,
+ )
+
+ def _check_self_post_rating(self, post_id, user_id):
+ self_like_check_query = "SELECT user_id FROM post WHERE post_id = ?"
+ self._execute_db_command(self_like_check_query, (post_id, ))
+ result = self.db_cursor.fetchone()
+ if result and result[0] == user_id:
+ error_message = ("Users are not allowed to like/dislike their own "
+ "posts.")
+ return {"success": False, "error": error_message}
+ else:
+ return None
+
+ def _check_self_comment_rating(self, comment_id, user_id):
+ self_like_check_query = ("SELECT user_id FROM comment WHERE "
+ "comment_id = ?")
+ self._execute_db_command(self_like_check_query, (comment_id, ))
+ result = self.db_cursor.fetchone()
+ if result and result[0] == user_id:
+ error_message = ("Users are not allowed to like/dislike their "
+ "own comments.")
+ return {"success": False, "error": error_message}
+ else:
+ return None
+
+ def _get_post_type(self, post_id: int):
+ query = (
+ "SELECT original_post_id, quote_content FROM post WHERE post_id "
+ "= ?")
+ self._execute_db_command(query, (post_id, ))
+ result = self.db_cursor.fetchone()
+
+ if not result:
+ return None
+
+ original_post_id, quote_content = result
+
+ if original_post_id is None:
+ # common post without quote or repost
+ return {"type": "common", "root_post_id": None}
+ elif quote_content is None:
+ # post with repost
+ return {"type": "repost", "root_post_id": original_post_id}
+ else:
+ # post with quote
+ return {"type": "quote", "root_post_id": original_post_id}
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/process_recsys_posts.py b/backend/vendor/camel-oasis/oasis/social_platform/process_recsys_posts.py
new file mode 100644
index 00000000..6181f94b
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/process_recsys_posts.py
@@ -0,0 +1,81 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from typing import List
+
+import torch
+from camel.embeddings import OpenAIEmbedding
+from camel.types import EmbeddingModelType
+from transformers import AutoModel, AutoTokenizer
+
+
+# Function: Process each batch
+@torch.no_grad()
+def process_batch(model: AutoModel, tokenizer: AutoTokenizer,
+ batch_texts: List[str]):
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ inputs = tokenizer(batch_texts,
+ return_tensors="pt",
+ padding=True,
+ truncation=True)
+ inputs = {key: value.to(device) for key, value in inputs.items()}
+ outputs = model(**inputs)
+ return outputs.pooler_output
+
+
+def generate_post_vector(model: AutoModel, tokenizer: AutoTokenizer, texts,
+ batch_size):
+ # Loop through all messages
+ # If the list of messages is too large, process them in batches.
+ all_outputs = []
+ for i in range(0, len(texts), batch_size):
+ batch_texts = texts[i:i + batch_size]
+ batch_outputs = process_batch(model, tokenizer, batch_texts)
+ all_outputs.append(batch_outputs)
+ all_outputs_tensor = torch.cat(all_outputs, dim=0) # num_posts x dimension
+ return all_outputs_tensor.cpu()
+
+
+def generate_post_vector_openai(texts: List[str], batch_size: int = 100):
+ """
+ Generate embeddings using OpenAI API
+
+ Args:
+ texts: List of texts to process
+ batch_size: Size of each batch
+ """
+ openai_embedding = OpenAIEmbedding(
+ model_type=EmbeddingModelType.TEXT_EMBEDDING_3_SMALL)
+
+ all_embeddings = []
+ for i in range(0, len(texts), batch_size):
+ batch_texts = texts[i:i + batch_size]
+ cleaned_texts = [
+ text.strip() if text and isinstance(text, str) else "empty"
+ for text in batch_texts
+ ]
+ batch_embeddings = openai_embedding.embed_list(objs=cleaned_texts)
+ batch_tensor = torch.tensor(batch_embeddings)
+ all_embeddings.append(batch_tensor)
+
+ return torch.cat(all_embeddings, dim=0)
+
+
+if __name__ == "__main__":
+ # Input list of strings (assuming there are tens of thousands of messages)
+ # Here, the same message is repeated 10000 times as an example
+ texts = ["I'm using TwHIN-BERT! #TwHIN-BERT #NLP"] * 10000
+ # Define batch size
+ batch_size = 100
+ all_outputs_tensor = generate_post_vector(texts, batch_size)
+ print(all_outputs_tensor.shape)
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/recsys.py b/backend/vendor/camel-oasis/oasis/social_platform/recsys.py
new file mode 100644
index 00000000..9d9429cb
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/recsys.py
@@ -0,0 +1,797 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+'''Note that you need to check if it exceeds max_rec_post_len when writing
+into rec_matrix'''
+import heapq
+import logging
+import random
+import time
+from ast import literal_eval
+from datetime import datetime
+from math import log
+from typing import Any, Dict, List
+
+import numpy as np
+import torch
+from sentence_transformers import SentenceTransformer
+from sklearn.feature_extraction.text import TfidfVectorizer
+from sklearn.metrics.pairwise import cosine_similarity
+
+from .process_recsys_posts import (generate_post_vector,
+ generate_post_vector_openai)
+from .typing import ActionType, RecsysType
+
+rec_log = logging.getLogger(name='social.rec')
+rec_log.setLevel('DEBUG')
+
+# Initially set to None, to be assigned once again in the recsys function
+model = None
+twhin_tokenizer = None
+twhin_model = None
+
+# Create the TF-IDF model
+tfidf_vectorizer = TfidfVectorizer()
+# Prepare the twhin model
+device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+# All historical tweets and the most recent tweet of each user
+user_previous_post_all = {}
+user_previous_post = {}
+user_profiles = []
+# Get the {post_id: content} dict
+t_items = {}
+# Get the {uid: follower_count} dict
+# It's necessary to ensure that agent registration is sequential, with the
+# relationship of user_id=agent_id+1; disorder in registration will cause
+# issues here
+u_items = {}
+# Get the creation times of all tweets, assigning scores based on how recent
+# they are
+date_score = []
+
+
+def get_twhin_tokenizer():
+ global twhin_tokenizer
+ if twhin_tokenizer is None:
+ from transformers import AutoTokenizer
+ twhin_tokenizer = AutoTokenizer.from_pretrained(
+ pretrained_model_name_or_path="Twitter/twhin-bert-base",
+ model_max_length=512)
+ return twhin_tokenizer
+
+
+def get_twhin_model(device):
+ global twhin_model
+ if twhin_model is None:
+ from transformers import AutoModel
+ twhin_model = AutoModel.from_pretrained(
+ pretrained_model_name_or_path="Twitter/twhin-bert-base").to(device)
+ return twhin_model
+
+
+def load_model(model_name):
+ try:
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ if model_name == 'paraphrase-MiniLM-L6-v2':
+ return SentenceTransformer(model_name,
+ device=device,
+ cache_folder="./models")
+ elif model_name == 'Twitter/twhin-bert-base':
+ twhin_tokenizer = get_twhin_tokenizer()
+ twhin_model = get_twhin_model(device)
+ return twhin_tokenizer, twhin_model
+ else:
+ raise ValueError(f"Unknown model name: {model_name}")
+ except Exception as e:
+ raise Exception(f"Failed to load the model: {model_name}") from e
+
+
+def get_recsys_model(recsys_type: str = None):
+ if recsys_type == RecsysType.TWITTER.value:
+ model = load_model('paraphrase-MiniLM-L6-v2')
+ return model
+ elif recsys_type == RecsysType.TWHIN.value:
+ twhin_tokenizer, twhin_model = load_model("Twitter/twhin-bert-base")
+ models = (twhin_tokenizer, twhin_model)
+ return models
+ elif (recsys_type == RecsysType.REDDIT.value
+ or recsys_type == RecsysType.RANDOM.value):
+ return None
+ else:
+ raise ValueError(f"Unknown recsys type: {recsys_type}")
+
+
+# Move model to GPU if available
+device = 'cuda' if torch.cuda.is_available() else 'cpu'
+if model is not None:
+ model.to(device)
+else:
+ pass
+
+
+# Reset global variables
+def reset_globals():
+ global user_previous_post_all, user_previous_post
+ global user_profiles, t_items, u_items
+ global date_score
+ user_previous_post_all = {}
+ user_previous_post = {}
+ user_profiles = []
+ t_items = {}
+ u_items = {}
+ date_score = []
+
+
+def rec_sys_random(post_table: List[Dict[str, Any]], rec_matrix: List[List],
+ max_rec_post_len: int) -> List[List]:
+ """
+ Randomly recommend posts to users.
+
+ Args:
+ user_table (List[Dict[str, Any]]): List of users.
+ post_table (List[Dict[str, Any]]): List of posts.
+ trace_table (List[Dict[str, Any]]): List of user interactions.
+ rec_matrix (List[List]): Existing recommendation matrix.
+ max_rec_post_len (int): Maximum number of recommended posts.
+
+ Returns:
+ List[List]: Updated recommendation matrix.
+ """
+ # Get all post IDs
+ post_ids = [post['post_id'] for post in post_table]
+ new_rec_matrix = []
+ if len(post_ids) <= max_rec_post_len:
+ # If the number of posts is less than or equal to the maximum number
+ # of recommendations, each user gets all post IDs
+ new_rec_matrix = [post_ids] * len(rec_matrix)
+ else:
+ # If the number of posts is greater than the maximum number of
+ # recommendations, each user randomly gets a specified number of post
+ # IDs
+ for _ in range(len(rec_matrix)):
+ new_rec_matrix.append(random.sample(post_ids, max_rec_post_len))
+
+ return new_rec_matrix
+
+
+def calculate_hot_score(num_likes: int, num_dislikes: int,
+ created_at: datetime) -> int:
+ """
+ Compute the hot score for a post.
+
+ Args:
+ num_likes (int): Number of likes.
+ num_dislikes (int): Number of dislikes.
+ created_at (datetime): Creation time of the post.
+
+ Returns:
+ int: Hot score of the post.
+
+ Reference:
+ https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
+ """
+ s = num_likes - num_dislikes
+ order = log(max(abs(s), 1), 10)
+ sign = 1 if s > 0 else -1 if s < 0 else 0
+
+ # epoch_seconds
+ epoch = datetime(1970, 1, 1)
+ td = created_at - epoch
+ epoch_seconds_result = td.days * 86400 + td.seconds + (
+ float(td.microseconds) / 1e6)
+
+ seconds = epoch_seconds_result - 1134028003
+ return round(sign * order + seconds / 45000, 7)
+
+
+def get_recommendations(
+ user_index,
+ cosine_similarities,
+ items,
+ score,
+ top_n=100,
+):
+ similarities = np.array(cosine_similarities[user_index])
+ similarities = similarities * score
+ top_item_indices = similarities.argsort()[::-1][:top_n]
+ recommended_items = [(list(items.keys())[i], similarities[i])
+ for i in top_item_indices]
+ return recommended_items
+
+
+def rec_sys_reddit(post_table: List[Dict[str, Any]], rec_matrix: List[List],
+ max_rec_post_len: int) -> List[List]:
+ """
+ Recommend posts based on Reddit-like hot score.
+
+ Args:
+ post_table (List[Dict[str, Any]]): List of posts.
+ rec_matrix (List[List]): Existing recommendation matrix.
+ max_rec_post_len (int): Maximum number of recommended posts.
+
+ Returns:
+ List[List]: Updated recommendation matrix.
+ """
+ # Get all post IDs
+ post_ids = [post['post_id'] for post in post_table]
+
+ if len(post_ids) <= max_rec_post_len:
+ # If the number of posts is less than or equal to the maximum number
+ # of recommendations, each user gets all post IDs
+ new_rec_matrix = [post_ids] * len(rec_matrix)
+ else:
+ # The time complexity of this recommendation system is
+ # O(post_num * log max_rec_post_len)
+ all_hot_score = []
+ for post in post_table:
+ try:
+ created_at_dt = datetime.strptime(post['created_at'],
+ "%Y-%m-%d %H:%M:%S.%f")
+ except Exception:
+ created_at_dt = datetime.strptime(post['created_at'],
+ "%Y-%m-%d %H:%M:%S")
+ hot_score = calculate_hot_score(post['num_likes'],
+ post['num_dislikes'],
+ created_at_dt)
+ all_hot_score.append((hot_score, post['post_id']))
+ # Sort
+ top_posts = heapq.nlargest(max_rec_post_len,
+ all_hot_score,
+ key=lambda x: x[0])
+ top_post_ids = [post_id for _, post_id in top_posts]
+
+ # If the number of posts is greater than the maximum number of
+ # recommendations, each user gets a specified number of post IDs
+ # randomly
+ new_rec_matrix = [top_post_ids] * len(rec_matrix)
+
+ return new_rec_matrix
+
+
+def rec_sys_personalized(user_table: List[Dict[str, Any]],
+ post_table: List[Dict[str, Any]],
+ trace_table: List[Dict[str,
+ Any]], rec_matrix: List[List],
+ max_rec_post_len: int) -> List[List]:
+ """
+ Recommend posts based on personalized similarity scores.
+
+ Args:
+ user_table (List[Dict[str, Any]]): List of users.
+ post_table (List[Dict[str, Any]]): List of posts.
+ trace_table (List[Dict[str, Any]]): List of user interactions.
+ rec_matrix (List[List]): Existing recommendation matrix.
+ max_rec_post_len (int): Maximum number of recommended posts.
+
+ Returns:
+ List[List]: Updated recommendation matrix.
+ """
+ global model
+ if model is None or isinstance(model, tuple):
+ model = get_recsys_model(recsys_type="twitter")
+
+ post_ids = [post['post_id'] for post in post_table]
+ print(
+ f'Running personalized recommendation for {len(user_table)} users...')
+ start_time = time.time()
+ new_rec_matrix = []
+ if len(post_ids) <= max_rec_post_len:
+ # If the number of posts is less than or equal to the maximum
+ # recommended length, each user gets all post IDs
+ new_rec_matrix = [post_ids] * len(rec_matrix)
+ else:
+ # If the number of posts is greater than the maximum recommended
+ # length, each user gets personalized post IDs
+ user_bios = [
+ user['bio'] if 'bio' in user and user['bio'] is not None else ''
+ for user in user_table
+ ]
+ post_contents = [post['content'] for post in post_table]
+
+ if model:
+ user_embeddings = model.encode(user_bios,
+ convert_to_tensor=True,
+ device=device)
+ post_embeddings = model.encode(post_contents,
+ convert_to_tensor=True,
+ device=device)
+
+ # Compute dot product similarity
+ dot_product = torch.matmul(user_embeddings, post_embeddings.T)
+
+ # Compute norm
+ user_norms = torch.norm(user_embeddings, dim=1)
+ post_norms = torch.norm(post_embeddings, dim=1)
+
+ # Compute cosine similarity
+ similarities = dot_product / (user_norms[:, None] *
+ post_norms[None, :])
+
+ else:
+ # Generate random similarities
+ similarities = torch.rand(len(user_table), len(post_table))
+
+ # Iterate through each user to generate personalized recommendations.
+ for user_index, user in enumerate(user_table):
+ # Filter out posts made by the current user.
+ filtered_post_indices = [
+ i for i, post in enumerate(post_table)
+ if post['user_id'] != user['user_id']
+ ]
+
+ user_similarities = similarities[user_index, filtered_post_indices]
+
+ # Get the corresponding post IDs for the filtered posts.
+ filtered_post_ids = [
+ post_table[i]['post_id'] for i in filtered_post_indices
+ ]
+
+ # Determine the top posts based on the similarities, limited by
+ # max_rec_post_len.
+ _, top_indices = torch.topk(user_similarities,
+ k=min(max_rec_post_len,
+ len(filtered_post_ids)))
+
+ top_post_ids = [filtered_post_ids[i] for i in top_indices.tolist()]
+
+ # Append the top post IDs to the new recommendation matrix.
+ new_rec_matrix.append(top_post_ids)
+
+ end_time = time.time()
+ print(f'Personalized recommendation time: {end_time - start_time:.6f}s')
+ return new_rec_matrix
+
+
+def get_like_post_id(user_id, action, trace_table):
+ """
+ Get the post IDs that a user has liked or unliked.
+
+ Args:
+ user_id (str): ID of the user.
+ action (str): Type of action (like or unlike).
+ post_table (list): List of posts.
+ trace_table (list): List of user interactions.
+
+ Returns:
+ list: List of post IDs.
+ """
+ # Get post IDs from trace table for the given user and action
+ trace_post_ids = [
+ literal_eval(trace['info'])["post_id"] for trace in trace_table
+ if (trace['user_id'] == user_id and trace['action'] == action)
+ ]
+ """Only take the last 5 liked posts, if not enough, pad with the most
+ recently liked post. Only take IDs, not content, because calculating
+ embeddings for all posts again is very time-consuming, especially when the
+ number of agents is large"""
+ if len(trace_post_ids) < 5 and len(trace_post_ids) > 0:
+ trace_post_ids += [trace_post_ids[-1]] * (5 - len(trace_post_ids))
+ elif len(trace_post_ids) > 5:
+ trace_post_ids = trace_post_ids[-5:]
+ else:
+ trace_post_ids = [0]
+
+ return trace_post_ids
+
+
+# Calculate the average cosine similarity between liked posts and target posts
+def calculate_like_similarity(liked_vectors, target_vectors):
+ # Calculate the norms of the vectors
+ liked_norms = np.linalg.norm(liked_vectors, axis=1)
+ target_norms = np.linalg.norm(target_vectors, axis=1)
+ # Calculate dot products
+ dot_products = np.dot(target_vectors, liked_vectors.T)
+ # Calculate cosine similarities
+ cosine_similarities = dot_products / np.outer(target_norms, liked_norms)
+ # Take the average
+ average_similarities = np.mean(cosine_similarities, axis=1)
+
+ return average_similarities
+
+
+def coarse_filtering(input_list, scale):
+ """
+ Coarse filtering posts and return selected elements with their indices.
+ """
+ if len(input_list) <= scale:
+ # Return elements and their indices as list of tuples (element, index)
+ sampled_indices = range(len(input_list))
+ return (input_list, sampled_indices)
+ else:
+ # Get random sample of scale elements
+ sampled_indices = random.sample(range(len(input_list)), scale)
+ sampled_elements = [input_list[idx] for idx in sampled_indices]
+ # return [(input_list[idx], idx) for idx in sampled_indices]
+ return (sampled_elements, sampled_indices)
+
+
+def rec_sys_personalized_twh(
+ user_table: List[Dict[str, Any]],
+ post_table: List[Dict[str, Any]],
+ latest_post_count: int,
+ trace_table: List[Dict[str, Any]],
+ rec_matrix: List[List],
+ max_rec_post_len: int,
+ current_time: int,
+ # source_post_indexs: List[int],
+ recall_only: bool = False,
+ enable_like_score: bool = False,
+ use_openai_embedding: bool = False) -> List[List]:
+ global twhin_model, twhin_tokenizer
+ if twhin_model is None or twhin_tokenizer is None:
+ twhin_tokenizer, twhin_model = get_recsys_model(
+ recsys_type="twhin-bert")
+ # Set some global variables to reduce time consumption
+ global date_score, t_items, u_items, user_previous_post
+ global user_previous_post_all, user_profiles
+ # Get the uid: follower_count dict
+ # Update only once, unless adding the feature to include new users midway.
+ if (not u_items) or len(u_items) != len(user_table):
+ u_items = {
+ user['user_id']: user["num_followers"]
+ for user in user_table
+ }
+ if not user_previous_post_all or len(user_previous_post_all) != len(
+ user_table):
+ # Each user must have a list of historical tweets
+ user_previous_post_all = {
+ index: []
+ for index in range(len(user_table))
+ }
+ user_previous_post = {index: "" for index in range(len(user_table))}
+ if not user_profiles or len(user_profiles) != len(user_table):
+ for user in user_table:
+ if user['bio'] is None:
+ user_profiles.append('This user does not have profile')
+ else:
+ user_profiles.append(user['bio'])
+
+ if len(t_items) < len(post_table):
+ for post in post_table[-latest_post_count:]:
+ # Get the {post_id: content} dict, update only the latest tweets
+ t_items[post['post_id']] = post['content']
+ # Update the user's historical tweets
+ user_previous_post_all[post['user_id']].append(post['content'])
+ user_previous_post[post['user_id']] = post['content']
+ # Get the creation times of all tweets, assigning scores based on
+ # how recent they are, note that this algorithm can run for a
+ # maximum of 90 time steps
+ date_score.append(
+ np.log(
+ (271.8 - (current_time - int(post['created_at']))) / 100))
+
+ date_score_np = np.array(date_score)
+
+ if enable_like_score:
+ # Calculate similarity with previously liked content, first gather
+ # liked post ids from the trace
+ like_post_ids_all = []
+ for user in user_table:
+ user_id = user['agent_id']
+ like_post_ids = get_like_post_id(user_id,
+ ActionType.LIKE_POST.value,
+ trace_table)
+ like_post_ids_all.append(like_post_ids)
+ scores = date_score_np
+ new_rec_matrix = []
+ if len(post_table) <= max_rec_post_len:
+ # If the number of tweets is less than or equal to the max
+ # recommendation count, each user gets all post IDs
+ tids = [t['post_id'] for t in post_table]
+ new_rec_matrix = [tids] * (len(rec_matrix))
+
+ else:
+ # If the number of tweets is greater than the max recommendation
+ # count, each user randomly gets personalized post IDs
+
+ # This requires going through all users to update their profiles,
+ # which is a time-consuming operation
+ for post_user_index in user_previous_post:
+ try:
+ # Directly replacing the profile with the latest tweet will
+ # cause the recommendation system to repeatedly push other
+ # reposts to users who have already shared that tweet
+ # user_profiles[post_user_index] =
+ # user_previous_post[post_user_index]
+ # Instead, append the description of the Recent post's content
+ # to the end of the user char
+ update_profile = (
+ f" # Recent post:{user_previous_post[post_user_index]}")
+ if user_previous_post[post_user_index] != "":
+ # If there's no update for the recent post, add this part
+ if "# Recent post:" not in user_profiles[post_user_index]:
+ user_profiles[post_user_index] += update_profile
+ # If the profile has a recent post but it's not the user's
+ # latest, replace it
+ elif update_profile not in user_profiles[post_user_index]:
+ user_profiles[post_user_index] = user_profiles[
+ post_user_index].split(
+ "# Recent post:")[0] + update_profile
+ except Exception:
+ print("update previous post failed")
+
+ # coarse filtering 4000 posts due to the memory constraint.
+ filtered_posts_tuple = coarse_filtering(list(t_items.values()), 4000)
+ corpus = user_profiles + filtered_posts_tuple[0]
+ # corpus = user_profiles + list(t_items.values())
+ tweet_vector_start_t = time.time()
+ if use_openai_embedding:
+ all_post_vector_list = generate_post_vector_openai(corpus,
+ batch_size=1000)
+ else:
+ all_post_vector_list = generate_post_vector(twhin_model,
+ twhin_tokenizer,
+ corpus,
+ batch_size=1000)
+ tweet_vector_end_t = time.time()
+ rec_log.info(
+ f"twhin model cost time: {tweet_vector_end_t-tweet_vector_start_t}"
+ )
+ user_vector = all_post_vector_list[:len(user_profiles)]
+ posts_vector = all_post_vector_list[len(user_profiles):]
+
+ if enable_like_score:
+ # Traverse all liked post ids, collecting liked post vectors from
+ # posts_vector for matrix acceleration calculation
+ like_posts_vectors = []
+ for user_idx, like_post_ids in enumerate(like_post_ids_all):
+ if len(like_post_ids) != 1:
+ for like_post_id in like_post_ids:
+ try:
+ like_posts_vectors.append(
+ posts_vector[like_post_id - 1])
+ except Exception:
+ like_posts_vectors.append(user_vector[user_idx])
+ else:
+ like_posts_vectors += [
+ user_vector[user_idx] for _ in range(5)
+ ]
+ try:
+ like_posts_vectors = torch.stack(like_posts_vectors).view(
+ len(user_table), 5, posts_vector.shape[1])
+ except Exception:
+ import pdb # noqa: F811
+ pdb.set_trace()
+ get_similar_start_t = time.time()
+ cosine_similarities = cosine_similarity(user_vector, posts_vector)
+ get_similar_end_t = time.time()
+ rec_log.info(f"get cosine_similarity time: "
+ f"{get_similar_end_t-get_similar_start_t}")
+ if enable_like_score:
+ for user_index, profile in enumerate(user_profiles):
+ user_like_posts_vector = like_posts_vectors[user_index]
+ like_scores = calculate_like_similarity(
+ user_like_posts_vector, posts_vector)
+ try:
+ scores = scores + like_scores
+ except Exception:
+ import pdb
+ pdb.set_trace()
+
+ filter_posts_index = filtered_posts_tuple[1]
+ cosine_similarities = cosine_similarities * scores[filter_posts_index]
+ cosine_similarities = torch.tensor(cosine_similarities)
+ value, indices = torch.topk(cosine_similarities,
+ max_rec_post_len,
+ dim=1,
+ largest=True,
+ sorted=True)
+ filter_posts_index = torch.tensor(filter_posts_index)
+ indices = filter_posts_index[indices]
+ # cosine_similarities = cosine_similarities * scores
+ # cosine_similarities = torch.tensor(cosine_similarities)
+ # value, indices = torch.topk(cosine_similarities,
+ # max_rec_post_len,
+ # dim=1,
+ # largest=True,
+ # sorted=True)
+
+ matrix_list = indices.cpu().numpy()
+ post_list = list(t_items.keys())
+ for rec_ids in matrix_list:
+ rec_ids = [post_list[i] for i in rec_ids]
+ new_rec_matrix.append(rec_ids)
+
+ return new_rec_matrix
+
+
+def normalize_similarity_adjustments(post_scores, base_similarity,
+ like_similarity, dislike_similarity):
+ """
+ Normalize the adjustments to keep them in scale with overall similarities.
+
+ Args:
+ post_scores (list): List of post scores.
+ base_similarity (float): Base similarity score.
+ like_similarity (float): Similarity score for liked posts.
+ dislike_similarity (float): Similarity score for disliked posts.
+
+ Returns:
+ float: Adjusted similarity score.
+ """
+ if len(post_scores) == 0:
+ return base_similarity
+
+ max_score = max(post_scores, key=lambda x: x[1])[1]
+ min_score = min(post_scores, key=lambda x: x[1])[1]
+ score_range = max_score - min_score
+ adjustment = (like_similarity - dislike_similarity) * (score_range / 2)
+ return base_similarity + adjustment
+
+
+def swap_random_posts(rec_post_ids, post_ids, swap_percent=0.1):
+ """
+ Swap a percentage of recommended posts with random posts.
+
+ Args:
+ rec_post_ids (list): List of recommended post IDs.
+ post_ids (list): List of all post IDs.
+ swap_percent (float): Percentage of posts to swap.
+
+ Returns:
+ list: Updated list of recommended post IDs.
+ """
+ num_to_swap = int(len(rec_post_ids) * swap_percent)
+ posts_to_swap = random.sample(post_ids, num_to_swap)
+ indices_to_replace = random.sample(range(len(rec_post_ids)), num_to_swap)
+
+ for idx, new_post in zip(indices_to_replace, posts_to_swap):
+ rec_post_ids[idx] = new_post
+
+ return rec_post_ids
+
+
+def get_trace_contents(user_id, action, post_table, trace_table):
+ """
+ Get the contents of posts that a user has interacted with.
+
+ Args:
+ user_id (str): ID of the user.
+ action (str): Type of action (like or unlike).
+ post_table (list): List of posts.
+ trace_table (list): List of user interactions.
+
+ Returns:
+ list: List of post contents.
+ """
+ # Get post IDs from trace table for the given user and action
+ trace_post_ids = [
+ trace['post_id'] for trace in trace_table
+ if (trace['user_id'] == user_id and trace['action'] == action)
+ ]
+ # Fetch post contents from post table where post IDs match those in the
+ # trace
+ trace_contents = [
+ post['content'] for post in post_table
+ if post['post_id'] in trace_post_ids
+ ]
+ return trace_contents
+
+
+def rec_sys_personalized_with_trace(
+ user_table: List[Dict[str, Any]],
+ post_table: List[Dict[str, Any]],
+ trace_table: List[Dict[str, Any]],
+ rec_matrix: List[List],
+ max_rec_post_len: int,
+ swap_rate: float = 0.1,
+) -> List[List]:
+ """
+ This version:
+ 1. If the number of posts is less than or equal to the maximum
+ recommended length, each user gets all post IDs
+
+ 2. Otherwise:
+ - For each user, get a like-trace pool and dislike-trace pool from the
+ trace table
+ - For each user, calculate the similarity between the user's bio and
+ the post text
+ - Use the trace table to adjust the similarity score
+ - Swap 10% of the recommended posts with the random posts
+
+ Personalized recommendation system that uses user interaction traces.
+
+ Args:
+ user_table (List[Dict[str, Any]]): List of users.
+ post_table (List[Dict[str, Any]]): List of posts.
+ trace_table (List[Dict[str, Any]]): List of user interactions.
+ rec_matrix (List[List]): Existing recommendation matrix.
+ max_rec_post_len (int): Maximum number of recommended posts.
+ swap_rate (float): Percentage of posts to swap for diversity.
+
+ Returns:
+ List[List]: Updated recommendation matrix.
+ """
+
+ start_time = time.time()
+
+ new_rec_matrix = []
+ post_ids = [post['post_id'] for post in post_table]
+ if len(post_ids) <= max_rec_post_len:
+ new_rec_matrix = [post_ids] * (len(rec_matrix) - 1)
+ else:
+ for idx in range(1, len(rec_matrix)):
+ user_id = user_table[idx - 1]['user_id']
+ user_bio = user_table[idx - 1]['bio']
+ # filter out posts that belong to the user
+ available_post_contents = [(post['post_id'], post['content'])
+ for post in post_table
+ if post['user_id'] != user_id]
+
+ # filter out like-trace and dislike-trace
+ like_trace_contents = get_trace_contents(
+ user_id, ActionType.LIKE_POST.value, post_table, trace_table)
+ dislike_trace_contents = get_trace_contents(
+ user_id, ActionType.UNLIKE_POST.value, post_table, trace_table)
+ # calculate similarity between user bio and post text
+ post_scores = []
+ for post_id, post_content in available_post_contents:
+ if model is not None:
+ user_embedding = model.encode(user_bio)
+ post_embedding = model.encode(post_content)
+ base_similarity = np.dot(
+ user_embedding,
+ post_embedding) / (np.linalg.norm(user_embedding) *
+ np.linalg.norm(post_embedding))
+ post_scores.append((post_id, base_similarity))
+ else:
+ post_scores.append((post_id, random.random()))
+
+ new_post_scores = []
+ # adjust similarity based on like and dislike traces
+ for _post_id, _base_similarity in post_scores:
+ _post_content = post_table[post_ids.index(_post_id)]['content']
+ like_similarity = sum(
+ np.dot(model.encode(_post_content), model.encode(like)) /
+ (np.linalg.norm(model.encode(_post_content)) *
+ np.linalg.norm(model.encode(like)))
+ for like in like_trace_contents) / len(
+ like_trace_contents) if like_trace_contents else 0
+ dislike_similarity = sum(
+ np.dot(model.encode(_post_content), model.encode(dislike))
+ / (np.linalg.norm(model.encode(_post_content)) *
+ np.linalg.norm(model.encode(dislike)))
+ for dislike in dislike_trace_contents) / len(
+ dislike_trace_contents
+ ) if dislike_trace_contents else 0
+
+ # Normalize and apply adjustments
+ adjusted_similarity = normalize_similarity_adjustments(
+ post_scores, _base_similarity, like_similarity,
+ dislike_similarity)
+ new_post_scores.append((_post_id, adjusted_similarity))
+
+ # sort posts by similarity
+ new_post_scores.sort(key=lambda x: x[1], reverse=True)
+ # extract post ids
+ rec_post_ids = [
+ post_id for post_id, _ in new_post_scores[:max_rec_post_len]
+ ]
+
+ if swap_rate > 0:
+ # swap the recommended posts with random posts
+ swap_free_ids = [
+ post_id for post_id in post_ids
+ if post_id not in rec_post_ids and post_id not in [
+ trace['post_id']
+ for trace in trace_table if trace['user_id']
+ ]
+ ]
+ rec_post_ids = swap_random_posts(rec_post_ids, swap_free_ids,
+ swap_rate)
+
+ new_rec_matrix.append(rec_post_ids)
+ end_time = time.time()
+ print(f'Personalized recommendation time: {end_time - start_time:.6f}s')
+ return new_rec_matrix
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/chat_group.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/chat_group.sql
new file mode 100644
index 00000000..1d00a494
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/chat_group.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS `chat_group` (
+ group_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/comment.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment.sql
new file mode 100644
index 00000000..71c70d46
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment.sql
@@ -0,0 +1,12 @@
+-- This is the schema definition for the comment table
+CREATE TABLE comment (
+ comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ post_id INTEGER,
+ user_id INTEGER,
+ content TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ num_likes INTEGER DEFAULT 0,
+ num_dislikes INTEGER DEFAULT 0,
+ FOREIGN KEY(post_id) REFERENCES post(post_id),
+ FOREIGN KEY(user_id) REFERENCES user(user_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_dislike.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_dislike.sql
new file mode 100644
index 00000000..b118d3a4
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_dislike.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the comment_dislike table
+CREATE TABLE comment_dislike (
+ comment_dislike_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ comment_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(comment_id) REFERENCES comment(comment_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_like.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_like.sql
new file mode 100644
index 00000000..d60c0a86
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/comment_like.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the comment_like table
+CREATE TABLE comment_like (
+ comment_like_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ comment_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(comment_id) REFERENCES comment(comment_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/dislike.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/dislike.sql
new file mode 100644
index 00000000..120df591
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/dislike.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the dislike table
+CREATE TABLE dislike (
+ dislike_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ post_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(post_id) REFERENCES tweet(post_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/follow.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/follow.sql
new file mode 100644
index 00000000..c747e82e
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/follow.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the follow table
+CREATE TABLE follow (
+ follow_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ follower_id INTEGER,
+ followee_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(follower_id) REFERENCES user(user_id),
+ FOREIGN KEY(followee_id) REFERENCES user(user_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/group_member.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/group_member.sql
new file mode 100644
index 00000000..3411cdb4
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/group_member.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS group_members (
+ group_id INTEGER NOT NULL,
+ agent_id INTEGER NOT NULL,
+ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (group_id, agent_id),
+ FOREIGN KEY (group_id) REFERENCES chat_group(group_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/group_message.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/group_message.sql
new file mode 100644
index 00000000..6b4eefdf
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/group_message.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS group_messages (
+ message_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER NOT NULL,
+ sender_id INTEGER NOT NULL,
+ content TEXT NOT NULL,
+ sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (group_id) REFERENCES chat_group(group_id),
+ FOREIGN KEY (sender_id) REFERENCES user(agent_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/like.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/like.sql
new file mode 100644
index 00000000..82c74076
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/like.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the like table
+CREATE TABLE like (
+ like_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ post_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(post_id) REFERENCES tweet(post_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/mute.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/mute.sql
new file mode 100644
index 00000000..cefa72c4
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/mute.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the mute table
+CREATE TABLE mute (
+ mute_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ muter_id INTEGER,
+ mutee_id INTEGER,
+ created_at DATETIME,
+ FOREIGN KEY(muter_id) REFERENCES user(user_id),
+ FOREIGN KEY(mutee_id) REFERENCES user(user_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/post.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/post.sql
new file mode 100644
index 00000000..d668299b
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/post.sql
@@ -0,0 +1,16 @@
+-- This is the schema definition for the post table
+-- Add Images, location etc.?
+CREATE TABLE post (
+ post_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ original_post_id INTEGER, -- NULL if this is an original post
+ content TEXT DEFAULT '', -- DEFAULT '' for initial posts
+ quote_content TEXT, -- NULL if this is an original post or a repost
+ created_at DATETIME,
+ num_likes INTEGER DEFAULT 0,
+ num_dislikes INTEGER DEFAULT 0,
+ num_shares INTEGER DEFAULT 0, -- num_shares = num_reposts + num_quotes
+ num_reports INTEGER DEFAULT 0,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(original_post_id) REFERENCES post(post_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/product.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/product.sql
new file mode 100644
index 00000000..ad8060b8
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/product.sql
@@ -0,0 +1,6 @@
+-- This is the schema definition for the product table
+CREATE TABLE product (
+ product_id INTEGER PRIMARY KEY,
+ product_name TEXT,
+ sales INTEGER DEFAULT 0
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/rec.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/rec.sql
new file mode 100644
index 00000000..89b6a694
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/rec.sql
@@ -0,0 +1,8 @@
+-- This is the schema definition for the rec table
+CREATE TABLE rec (
+ user_id INTEGER,
+ post_id INTEGER,
+ PRIMARY KEY(user_id, post_id),
+ FOREIGN KEY(user_id) REFERENCES user(user_id)
+ FOREIGN KEY(post_id) REFERENCES tweet(post_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/report.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/report.sql
new file mode 100644
index 00000000..232c2e0a
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/report.sql
@@ -0,0 +1,10 @@
+-- This is the schema definition for the report table
+CREATE TABLE report (
+ report_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ post_id INTEGER,
+ report_reason TEXT,
+ created_at DATETIME,
+ FOREIGN KEY(user_id) REFERENCES user(user_id),
+ FOREIGN KEY(post_id) REFERENCES post(post_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/trace.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/trace.sql
new file mode 100644
index 00000000..13b47afa
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/trace.sql
@@ -0,0 +1,9 @@
+-- This is the schema definition for the trace table
+CREATE TABLE trace (
+ user_id INTEGER,
+ created_at DATETIME,
+ action TEXT,
+ info TEXT,
+ PRIMARY KEY(user_id, created_at, action, info),
+ FOREIGN KEY(user_id) REFERENCES user(user_id)
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/schema/user.sql b/backend/vendor/camel-oasis/oasis/social_platform/schema/user.sql
new file mode 100644
index 00000000..a5b4d0d6
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/schema/user.sql
@@ -0,0 +1,11 @@
+-- This is the schema definition for the user table
+CREATE TABLE user (
+ user_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ agent_id INTEGER,
+ user_name TEXT,
+ name TEXT,
+ bio TEXT,
+ created_at DATETIME,
+ num_followings INTEGER DEFAULT 0,
+ num_followers INTEGER DEFAULT 0
+);
diff --git a/backend/vendor/camel-oasis/oasis/social_platform/typing.py b/backend/vendor/camel-oasis/oasis/social_platform/typing.py
new file mode 100644
index 00000000..0a5700e8
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/social_platform/typing.py
@@ -0,0 +1,90 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+from enum import Enum
+
+
+class ActionType(Enum):
+ EXIT = "exit"
+ REFRESH = "refresh"
+ SEARCH_USER = "search_user"
+ SEARCH_POSTS = "search_posts"
+ CREATE_POST = "create_post"
+ LIKE_POST = "like_post"
+ UNLIKE_POST = "unlike_post"
+ DISLIKE_POST = "dislike_post"
+ UNDO_DISLIKE_POST = "undo_dislike_post"
+ REPORT_POST = "report_post"
+ FOLLOW = "follow"
+ UNFOLLOW = "unfollow"
+ MUTE = "mute"
+ UNMUTE = "unmute"
+ TREND = "trend"
+ SIGNUP = "sign_up"
+ REPOST = "repost"
+ QUOTE_POST = "quote_post"
+ UPDATE_REC_TABLE = "update_rec_table"
+ CREATE_COMMENT = "create_comment"
+ LIKE_COMMENT = "like_comment"
+ UNLIKE_COMMENT = "unlike_comment"
+ DISLIKE_COMMENT = "dislike_comment"
+ UNDO_DISLIKE_COMMENT = "undo_dislike_comment"
+ DO_NOTHING = "do_nothing"
+ PURCHASE_PRODUCT = "purchase_product"
+ INTERVIEW = "interview"
+ JOIN_GROUP = "join_group"
+ LEAVE_GROUP = "leave_group"
+ SEND_TO_GROUP = "send_to_group"
+ CREATE_GROUP = "create_group"
+ LISTEN_FROM_GROUP = "listen_from_group"
+
+ @classmethod
+ def get_default_twitter_actions(cls):
+ return [
+ cls.CREATE_POST,
+ cls.LIKE_POST,
+ cls.REPOST,
+ cls.FOLLOW,
+ cls.DO_NOTHING,
+ cls.QUOTE_POST,
+ ]
+
+ @classmethod
+ def get_default_reddit_actions(cls):
+ return [
+ cls.LIKE_POST,
+ cls.DISLIKE_POST,
+ cls.CREATE_POST,
+ cls.CREATE_COMMENT,
+ cls.LIKE_COMMENT,
+ cls.DISLIKE_COMMENT,
+ cls.SEARCH_POSTS,
+ cls.SEARCH_USER,
+ cls.TREND,
+ cls.REFRESH,
+ cls.DO_NOTHING,
+ cls.FOLLOW,
+ cls.MUTE,
+ ]
+
+
+class RecsysType(Enum):
+ TWITTER = "twitter"
+ TWHIN = "twhin-bert"
+ REDDIT = "reddit"
+ RANDOM = "random"
+
+
+class DefaultPlatformType(Enum):
+ TWITTER = "twitter"
+ REDDIT = "reddit"
diff --git a/backend/vendor/camel-oasis/oasis/testing/__init__.py b/backend/vendor/camel-oasis/oasis/testing/__init__.py
new file mode 100644
index 00000000..d963f8cc
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/testing/__init__.py
@@ -0,0 +1,13 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
diff --git a/backend/vendor/camel-oasis/oasis/testing/show_db.py b/backend/vendor/camel-oasis/oasis/testing/show_db.py
new file mode 100644
index 00000000..986102d9
--- /dev/null
+++ b/backend/vendor/camel-oasis/oasis/testing/show_db.py
@@ -0,0 +1,64 @@
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+# Licensed under the Apache License, Version 2.0 (the “License”);
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an “AS IS” BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
+import logging
+import sqlite3
+from datetime import datetime
+
+table_log = logging.getLogger(name="table")
+table_log.setLevel("DEBUG")
+now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") # Modify here
+file_handler = logging.FileHandler(f"./log/table-{str(now)}.log",
+ encoding="utf-8")
+file_handler.setLevel("DEBUG")
+file_handler.setFormatter(logging.Formatter("%(message)s"))
+table_log.addHandler(file_handler)
+stream_handler = logging.StreamHandler()
+stream_handler.setLevel("DEBUG")
+stream_handler.setFormatter(logging.Formatter("%(message)s"))
+table_log.addHandler(stream_handler)
+
+
+def print_db_contents(db_file):
+ # Connect to the SQLite database
+ conn = sqlite3.connect(db_file)
+ cursor = conn.cursor()
+
+ # Retrieve and print all table names
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
+ tables = cursor.fetchall()
+ # print("Tables:", [table[0] for table in tables])
+ table_log.info("Tables:" + " ".join([str(table[0]) for table in tables]))
+
+ for table_name in tables:
+ # print(f"\nTable: {table_name[0]}")
+ table_log.info(f"\nTable: {table_name[0]}")
+ # Print table structure
+ cursor.execute(f"PRAGMA table_info({table_name[0]})")
+ columns = cursor.fetchall()
+ # print("Columns:")
+ table_log.info("Columns:")
+ for col in columns:
+ # print(f" {col[1]} ({col[2]})")
+ table_log.info(f" {col[1]} ({col[2]})")
+
+ # Print table contents
+ cursor.execute(f"SELECT * FROM {table_name[0]}")
+ rows = cursor.fetchall()
+ # print("Contents:")
+ table_log.info("Contents:")
+ for row in rows:
+ # print(" ", row)
+ table_log.info(" " + ", ".join(str(item) for item in row))
+ # Close the connection
+ conn.close()
diff --git a/backend/vendor/camel-oasis/pyproject.toml b/backend/vendor/camel-oasis/pyproject.toml
new file mode 100644
index 00000000..9d56db30
--- /dev/null
+++ b/backend/vendor/camel-oasis/pyproject.toml
@@ -0,0 +1,41 @@
+[project]
+name = "camel-oasis"
+version = "0.2.5.post1"
+description = "Open Agents Social Interaction Simulations on a Large Scale (vendored fork: relaxed neo4j pin to coexist with graphiti-core[falkordb])"
+authors = [{ name = "CAMEL-AI.org" }]
+readme = "README.md"
+license = { text = "Apache-2.0" }
+keywords = ["multi-agent-systems", "social-simulations"]
+requires-python = ">=3.11,<3.13"
+
+dependencies = [
+ "pandas==2.2.2",
+ "igraph==0.11.6",
+ "cairocffi==1.7.1",
+ "pillow==10.3.0",
+ "unstructured==0.13.7",
+ "sentence-transformers==3.0.0",
+ "prance==23.6.21.0",
+ "openapi-spec-validator==0.7.1",
+ "slack_sdk==3.31.0",
+ # Relaxed from ==5.23.0 to coexist with graphiti-core[falkordb]'s
+ # neo4j>=5.26.0 requirement. The MiroFish code paths (run_*_simulation.py
+ # + run_parallel_simulation.py) never pass a neo4j_config, so AgentGraph
+ # is constructed in-memory-only and the neo4j driver is never invoked.
+ "neo4j>=5.23.0",
+ "camel-ai==0.2.78",
+ "requests_oauthlib==2.0.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["oasis"]
+
+# Required because the parent project (MiroFish) declares this package as
+# a direct-reference local path dep ("camel-oasis @ file:///app/backend/vendor/camel-oasis").
+# Without this, hatchling refuses to build when uv is constructing the lock entry.
+[tool.hatch.metadata]
+allow-direct-references = true
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 00000000..138dc5b7
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,81 @@
+# MiroFish deploy overlay — agent.profikid.nl
+#
+# Project conventions (matches hermes, snake, wiki siblings):
+# /docker//docker-compose.yml
+# COMPOSE_PROJECT_NAME=
+# TRAEFIK_HOST=agent.profikid.nl
+#
+# Single port (3000) exposed to Traefik; nginx inside the container
+# serves the built frontend and reverse-proxies /api/* + /health to Flask.
+#
+# Memory graph: FalkorDB (in-memory graph store) on a shared network.
+
+services:
+ falkordb:
+ image: falkordb/falkordb:latest
+ container_name: mirofish-falkordb
+ restart: unless-stopped
+ expose:
+ - "6379"
+ volumes:
+ - falkordb-data:/data
+ healthcheck:
+ test: ["CMD-SHELL", "redis-cli ping | grep -q PONG || exit 1"]
+ interval: 15s
+ timeout: 3s
+ retries: 5
+ start_period: 20s
+ networks:
+ - mirofish_net
+
+ mirofish:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ image: mirofish:latest
+ container_name: mirofish
+ restart: unless-stopped
+ depends_on:
+ falkordb:
+ condition: service_healthy
+ env_file:
+ - secrets.env
+ environment:
+ FALKORDB_HOST: falkordb
+ FALKORDB_PORT: "6379"
+ expose:
+ - "3000"
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.rule=Host(`${COMPOSE_PROJECT_NAME:-mirofish}.${TRAEFIK_HOST:-agent.profikid.nl}`)
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.entrypoints=websecure
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}.tls.certresolver=letsencrypt
+ - traefik.http.services.${COMPOSE_PROJECT_NAME:-mirofish}.loadbalancer.server.port=3000
+ # HTTP -> HTTPS redirect
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.rule=Host(`${COMPOSE_PROJECT_NAME:-mirofish}.${TRAEFIK_HOST:-agent.profikid.nl}`)
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.entrypoints=web
+ - traefik.http.routers.${COMPOSE_PROJECT_NAME:-mirofish}-http.middlewares=redirect-to-https
+ - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
+ - traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
+ volumes:
+ - uploads:/app/backend/uploads
+ - flask-logs:/app/logs
+ - embedding-model:/root/.cache/huggingface # cache the sentence-transformers model
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 90s
+ networks:
+ - mirofish_net
+
+volumes:
+ uploads:
+ flask-logs:
+ falkordb-data:
+ embedding-model:
+
+networks:
+ mirofish_net:
+ name: mirofish_net
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 00000000..2c9cd263
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,65 @@
+worker_processes 1;
+pid /run/nginx.pid;
+error_log /var/log/nginx/error.log warn;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ keepalive_timeout 65;
+ client_max_body_size 100m; # upload endpoint accepts up to 50MB per file, several at once
+ access_log /var/log/nginx/access.log;
+
+ upstream flask_backend {
+ server 127.0.0.1:5001;
+ keepalive 8;
+ }
+
+ server {
+ listen 3000 default_server;
+ server_name _;
+
+ root /app/frontend/dist;
+ index index.html;
+
+ # Long timeouts: backend's ontology/profile/report generation can take minutes
+ proxy_connect_timeout 600s;
+ proxy_send_timeout 600s;
+ proxy_read_timeout 600s;
+ send_timeout 600s;
+
+ # Reverse-proxy /api/* and /health to Flask
+ location /api/ {
+ proxy_pass http://flask_backend/api/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off; # important for SSE / streamed responses
+ }
+
+ location = /health {
+ proxy_pass http://flask_backend/health;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # Vite emits /assets/. for JS/CSS; let nginx serve with proper mime
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ try_files $uri =404;
+ }
+
+ # SPA fallback: send everything else to index.html
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+ }
+}
diff --git a/start.sh b/start.sh
new file mode 100644
index 00000000..938dd2eb
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Start the Flask backend in the background, then run nginx in the foreground.
+# Both share the same container.
+
+set -e
+
+# Tell Flask to bind to localhost only (nginx fronts it)
+export FLASK_HOST=127.0.0.1
+export FLASK_PORT=5001
+# Disable Werkzeug reloader / debug for a single-process prod-like run
+export FLASK_DEBUG=False
+
+echo "[start.sh] launching Flask backend on 127.0.0.1:5001 ..."
+cd /app/backend
+uv run python run.py > /app/logs/flask.log 2>&1 &
+FLASK_PID=$!
+
+# Wait for Flask to be ready (max 60s)
+echo "[start.sh] waiting for Flask to respond on /health ..."
+i=0
+while [ $i -lt 60 ]; do
+ if curl -sSf -m 2 http://127.0.0.1:5001/health >/dev/null 2>&1; then
+ echo "[start.sh] Flask is up (pid $FLASK_PID)."
+ break
+ fi
+ i=$((i+1))
+ sleep 1
+done
+if [ $i -ge 60 ]; then
+ echo "[start.sh] WARN: Flask did not become ready in 60s; check /app/logs/flask.log"
+fi
+
+# Make sure nginx can write its pid file
+mkdir -p /run
+chown -R www-data:www-data /var/lib/nginx /var/log/nginx /run 2>/dev/null || true
+
+echo "[start.sh] launching nginx on 0.0.0.0:3000 ..."
+exec nginx -g "daemon off;"