Spaces:
Sleeping
Sleeping
Commit
·
ec75db4
1
Parent(s):
666b025
add changes
Browse files- .gitignore +175 -0
- LICENSE +21 -0
- app.py +414 -0
- email_stats.json +4 -0
- requirements.txt +10 -0
- tools.py +262 -0
.gitignore
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Byte-compiled / optimized / DLL files
|
2 |
+
__pycache__/
|
3 |
+
*.py[cod]
|
4 |
+
*$py.class
|
5 |
+
|
6 |
+
# C extensions
|
7 |
+
*.so
|
8 |
+
|
9 |
+
# Distribution / packaging
|
10 |
+
.Python
|
11 |
+
build/
|
12 |
+
develop-eggs/
|
13 |
+
dist/
|
14 |
+
downloads/
|
15 |
+
eggs/
|
16 |
+
.eggs/
|
17 |
+
lib/
|
18 |
+
lib64/
|
19 |
+
parts/
|
20 |
+
sdist/
|
21 |
+
var/
|
22 |
+
wheels/
|
23 |
+
share/python-wheels/
|
24 |
+
*.egg-info/
|
25 |
+
.installed.cfg
|
26 |
+
*.egg
|
27 |
+
MANIFEST
|
28 |
+
|
29 |
+
# PyInstaller
|
30 |
+
# Usually these files are written by a python script from a template
|
31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
32 |
+
*.manifest
|
33 |
+
*.spec
|
34 |
+
|
35 |
+
# Installer logs
|
36 |
+
pip-log.txt
|
37 |
+
pip-delete-this-directory.txt
|
38 |
+
|
39 |
+
# Unit test / coverage reports
|
40 |
+
htmlcov/
|
41 |
+
.tox/
|
42 |
+
.nox/
|
43 |
+
.coverage
|
44 |
+
.coverage.*
|
45 |
+
.cache
|
46 |
+
nosetests.xml
|
47 |
+
coverage.xml
|
48 |
+
*.cover
|
49 |
+
*.py,cover
|
50 |
+
.hypothesis/
|
51 |
+
.pytest_cache/
|
52 |
+
cover/
|
53 |
+
|
54 |
+
# Translations
|
55 |
+
*.mo
|
56 |
+
*.pot
|
57 |
+
|
58 |
+
# Django stuff:
|
59 |
+
*.log
|
60 |
+
local_settings.py
|
61 |
+
db.sqlite3
|
62 |
+
db.sqlite3-journal
|
63 |
+
|
64 |
+
# Flask stuff:
|
65 |
+
instance/
|
66 |
+
.webassets-cache
|
67 |
+
|
68 |
+
# Scrapy stuff:
|
69 |
+
.scrapy
|
70 |
+
|
71 |
+
# Sphinx documentation
|
72 |
+
docs/_build/
|
73 |
+
|
74 |
+
# PyBuilder
|
75 |
+
.pybuilder/
|
76 |
+
target/
|
77 |
+
|
78 |
+
# Jupyter Notebook
|
79 |
+
.ipynb_checkpoints
|
80 |
+
|
81 |
+
# IPython
|
82 |
+
profile_default/
|
83 |
+
ipython_config.py
|
84 |
+
|
85 |
+
# pyenv
|
86 |
+
# For a library or package, you might want to ignore these files since the code is
|
87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
88 |
+
# .python-version
|
89 |
+
|
90 |
+
# pipenv
|
91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
94 |
+
# install all needed dependencies.
|
95 |
+
#Pipfile.lock
|
96 |
+
|
97 |
+
# poetry
|
98 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
100 |
+
# commonly ignored for libraries.
|
101 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
102 |
+
#poetry.lock
|
103 |
+
|
104 |
+
# pdm
|
105 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
106 |
+
#pdm.lock
|
107 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
108 |
+
# in version control.
|
109 |
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
110 |
+
.pdm.toml
|
111 |
+
.pdm-python
|
112 |
+
.pdm-build/
|
113 |
+
|
114 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
115 |
+
__pypackages__/
|
116 |
+
|
117 |
+
# Celery stuff
|
118 |
+
celerybeat-schedule
|
119 |
+
celerybeat.pid
|
120 |
+
|
121 |
+
# SageMath parsed files
|
122 |
+
*.sage.py
|
123 |
+
|
124 |
+
# Environments
|
125 |
+
.env
|
126 |
+
.venv
|
127 |
+
env/
|
128 |
+
venv/
|
129 |
+
ENV/
|
130 |
+
env.bak/
|
131 |
+
venv.bak/
|
132 |
+
|
133 |
+
# Spyder project settings
|
134 |
+
.spyderproject
|
135 |
+
.spyproject
|
136 |
+
|
137 |
+
# Rope project settings
|
138 |
+
.ropeproject
|
139 |
+
|
140 |
+
# mkdocs documentation
|
141 |
+
/site
|
142 |
+
|
143 |
+
# mypy
|
144 |
+
.mypy_cache/
|
145 |
+
.dmypy.json
|
146 |
+
dmypy.json
|
147 |
+
|
148 |
+
# Pyre type checker
|
149 |
+
.pyre/
|
150 |
+
|
151 |
+
# pytype static type analyzer
|
152 |
+
.pytype/
|
153 |
+
|
154 |
+
# Cython debug symbols
|
155 |
+
cython_debug/
|
156 |
+
|
157 |
+
# PyCharm
|
158 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
159 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
160 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
161 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
162 |
+
#.idea/
|
163 |
+
|
164 |
+
# dotenv environment variable file
|
165 |
+
.env
|
166 |
+
|
167 |
+
#google docs api
|
168 |
+
credentials.json
|
169 |
+
|
170 |
+
check_email.py
|
171 |
+
email_sender.py
|
172 |
+
google_docs.py
|
173 |
+
notion_connect.py
|
174 |
+
|
175 |
+
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 harshSethi11
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
app.py
ADDED
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
import os
|
3 |
+
import datetime as DT
|
4 |
+
import pytz
|
5 |
+
import time
|
6 |
+
import json
|
7 |
+
import re
|
8 |
+
import random
|
9 |
+
import string
|
10 |
+
from transformers import AutoTokenizer
|
11 |
+
from tools import toolsInfo
|
12 |
+
|
13 |
+
from dotenv import load_dotenv
|
14 |
+
load_dotenv()
|
15 |
+
|
16 |
+
|
17 |
+
from groq import Groq
|
18 |
+
client = Groq(
|
19 |
+
api_key=os.environ.get("GROQ_API_KEY"),
|
20 |
+
)
|
21 |
+
model_def = "llama-3.1-70b-versatile"
|
22 |
+
toolsModel = "llama3-groq-70b-8192-tool-use-preview"
|
23 |
+
maxTokens = 8000
|
24 |
+
tokenizer = AutoTokenizer.from_pretrained("Xenova/Meta-Llama-3.1-Tokenizer")
|
25 |
+
|
26 |
+
def countTokens(text):
|
27 |
+
text = str(text)
|
28 |
+
tokens = tokenizer.encode(text, add_special_tokens=False)
|
29 |
+
return len(tokens)
|
30 |
+
|
31 |
+
SYSTEM_MSG = """
|
32 |
+
You are an intelligent, personalized email generator for cold outreach, guiding the user through a structured workflow.
|
33 |
+
Ensure clarity and conciseness in your communication. Ask only one question at a time. you dont need to write the word "step" while asking the question, these are for you to understand the flow
|
34 |
+
|
35 |
+
# Step 1: Determine the Purpose of the Email
|
36 |
+
- Prompt the user to select the purpose of the email, using well-formatted numbered options.
|
37 |
+
- Group the options by category (Acquiring Customers, Learning and Connecting, Jobs and Hiring).
|
38 |
+
- Provide only numbers for the options, not the categories.
|
39 |
+
- Include a line break after each category name.
|
40 |
+
|
41 |
+
Example:
|
42 |
+
##### Category: Acquiring Customers
|
43 |
+
1. Lead Generation (spark interest in possible customers)
|
44 |
+
2. Sales Outreach (directly contact decision-makers)
|
45 |
+
3. Partnership and Collaboration (build mutually beneficial relationships)
|
46 |
+
4. Event Promotion (invite people to webinars, conferences, or other events)
|
47 |
+
5. Case Study or Testimonial Requests (ask satisfied customers for testimonials)
|
48 |
+
|
49 |
+
##### Category: Learning and Connecting
|
50 |
+
6. Networking (establish connections with industry experts)
|
51 |
+
7. Market Research (gather information about target audiences or industries)
|
52 |
+
8. Career Advice (seek guidance from experienced professionals)
|
53 |
+
|
54 |
+
##### Category: Jobs and Hiring
|
55 |
+
9. Job Application (apply for job openings)
|
56 |
+
10. Job Referrals (ask for referrals or recommendations)
|
57 |
+
11. Recruitment (reach out to potential candidates)
|
58 |
+
|
59 |
+
- now, you have to collect details, make sure you behave in a smart way, if the user mentions before suppose the recipient is a hotel manager, just confirm if the industry of the recipeint is hospitality, dont behave like dumb and ask the recipients's industry from the user. similarly for other parameters as well
|
60 |
+
|
61 |
+
# Step 2: Collect Sender Details
|
62 |
+
- Ask the user for the following specific information:
|
63 |
+
1. **Objective**: Clarify the user's overall goal or purpose for sending the email (e.g., lead generation, networking, job application).
|
64 |
+
2. **Personal Details**: Collect the sender's name, role, and company information (if applicable).
|
65 |
+
3. **Industry**: Ask for the industry the sender works in to ensure relevance in the email content.
|
66 |
+
|
67 |
+
Example:
|
68 |
+
- "Please provide the objective for sending this email (e.g., Lead Generation, Networking)."
|
69 |
+
- "What are your personal details? Please provide your name, role, and company (if applicable)."
|
70 |
+
- "What industry are you in?"
|
71 |
+
|
72 |
+
# Step 3: Collect Recipient Information
|
73 |
+
- Ask the user for the following recipient-related details:
|
74 |
+
1. **Recipient Name**: Collect the recipient's name to personalize the email.
|
75 |
+
2. **Recipient Role**: Ask for the recipient's role in their company to tailor the email accordingly.
|
76 |
+
3. **Recipient Industry**: Confirm or inquire about the recipient's industry (if different from the sender's).
|
77 |
+
|
78 |
+
Example:
|
79 |
+
- "What is the name of the recipient you are sending this email to?"
|
80 |
+
- "What is the recipient's role in their company?"
|
81 |
+
- "What industry does the recipient work in?"
|
82 |
+
|
83 |
+
- very important: Collect any other specific details that would help personalize or customize the email based on the user’s objective.
|
84 |
+
For example, you might ask if the user has interacted with the recipient before or if they know specific pain points to address in the email.
|
85 |
+
|
86 |
+
- now that you have collected these details, now you have to execute step 4 first that is save info in google sheets and then only move forward
|
87 |
+
# Step 4: Save Information to Google Sheets
|
88 |
+
- Save the collected sender and recipient details in a Google Sheet for easy reference.
|
89 |
+
- Verify with the user whether they can view the information in the sheet.
|
90 |
+
|
91 |
+
# Step 5: Draft Two Email Variations
|
92 |
+
- Based on the collected details, generate **two distinct variations** of the email.
|
93 |
+
- Ensure that each email is well-structured
|
94 |
+
- Present the two variations with numbered options for easy selection by the user.
|
95 |
+
- now that you know the industry of recipient, try to highlight the pain points of people in that industry, try to generate trust
|
96 |
+
- Example structure for email body: (you dont have to display these steps, like introduction etc., you just have to give the variation of the mail according to these steps)
|
97 |
+
-----
|
98 |
+
pargraph 1:Introduction
|
99 |
+
-----
|
100 |
+
paragraph 2: Key Message
|
101 |
+
-----
|
102 |
+
paragraph 3: Call to Action
|
103 |
+
-----
|
104 |
+
- while printing the template, it should resemble how the actual email looks like
|
105 |
+
- Ask the user which version they prefer and whether they would like to finalize it. If not, continue refining the email based on their feedback.
|
106 |
+
- think again if you have asked the user if he/she wants to add some more details
|
107 |
+
- once the user agrees, save that selected template ((important: the actual selected email, not the variation number)) in the Google Sheet.
|
108 |
+
|
109 |
+
# Step 6: Finalize the Email
|
110 |
+
- ((very important: now that you have got the selected template, you need to add some more sentence in qualiity english as per the details entered to the mail to generate trust withinh the recipient))
|
111 |
+
- Once the user selects an email version, ask for any missing placeholder values (e.g. user's phone number)
|
112 |
+
- Incorporate the placeholder values into the final email and show the user the complete draft.
|
113 |
+
- Confirm with the user whether the final email looks good or if additional changes are needed,
|
114 |
+
|
115 |
+
# Step 7: Send the Email
|
116 |
+
- Once the user approves the final version, ask for the recipient's exact email ID.
|
117 |
+
- dont assume recipients email id on the basis of the information entered by the user, explicity ask the email to which this message has to be sent
|
118 |
+
- Do **not** send the email until a valid email ID has been provided.
|
119 |
+
- important: ((the email has to be well structured like it was in the template, with proper paragraphs, dont just combine all the info into one paragraph into one and send it. also in the sent email in can see newline characters, this is send through api, so make sure that the send email, doesnt have these charcters and is complete i.e exactly what user selected00
|
120 |
+
- think again if you have followed the above instruction in step 7, if not correct them.
|
121 |
+
- Upon receiving a valid email, send the final formatted email.
|
122 |
+
|
123 |
+
# Step 8: congratulate the user
|
124 |
+
- After successfully sending the email, congratulate the user
|
125 |
+
|
126 |
+
# Step 9: Repeat Process
|
127 |
+
- Offer the user the option to repeat the process for additional profiles or recipients.
|
128 |
+
"""
|
129 |
+
|
130 |
+
StartMsg = "HEY THERE!😊"
|
131 |
+
|
132 |
+
|
133 |
+
st.set_page_config(
|
134 |
+
page_title= "EmailGenie",
|
135 |
+
page_icon= "🧞♂️")
|
136 |
+
|
137 |
+
ipAddress = st.context.headers.get("x-forwarded-for")
|
138 |
+
|
139 |
+
|
140 |
+
def __nowInIST() -> DT.datetime:
|
141 |
+
return DT.datetime.now(pytz.timezone("Asia/Kolkata"))
|
142 |
+
|
143 |
+
|
144 |
+
def pprint(log: str):
|
145 |
+
now = __nowInIST()
|
146 |
+
now = now.strftime("%Y-%m-%d %H:%M:%S")
|
147 |
+
print(f"[{now}] [{ipAddress}] {log}")
|
148 |
+
|
149 |
+
|
150 |
+
pprint("\n")
|
151 |
+
|
152 |
+
st.markdown(
|
153 |
+
"""
|
154 |
+
<style>
|
155 |
+
@keyframes blinker {
|
156 |
+
0% {
|
157 |
+
opacity: 1;
|
158 |
+
}
|
159 |
+
50% {
|
160 |
+
opacity: 0.2;
|
161 |
+
}
|
162 |
+
100% {
|
163 |
+
opacity: 1;
|
164 |
+
}
|
165 |
+
}
|
166 |
+
.blinking {
|
167 |
+
animation: blinker 3s ease-out infinite;
|
168 |
+
}
|
169 |
+
.code {
|
170 |
+
color: green;
|
171 |
+
border-radius: 3px;
|
172 |
+
padding: 2px 4px; /* Padding around the text */
|
173 |
+
font-family: 'Courier New', Courier, monospace; /* Monospace font */
|
174 |
+
}
|
175 |
+
div[aria-label="dialog"] {
|
176 |
+
width: 90vw;
|
177 |
+
}
|
178 |
+
</style>
|
179 |
+
""",
|
180 |
+
unsafe_allow_html=True
|
181 |
+
)
|
182 |
+
|
183 |
+
|
184 |
+
def isInvalidResponse(response: str):
|
185 |
+
# new line followed by small case char
|
186 |
+
if len(re.findall(r'\n[a-z]', response)) > 3:
|
187 |
+
return True
|
188 |
+
|
189 |
+
# lot of repeating words
|
190 |
+
if len(re.findall(r'\b(\w+)(\s+\1){2,}\b', response)) > 1:
|
191 |
+
return True
|
192 |
+
|
193 |
+
# lots of paragraphs
|
194 |
+
if len(re.findall(r'\n\n', response)) > 25:
|
195 |
+
return True
|
196 |
+
|
197 |
+
def resetButtonState():
|
198 |
+
st.session_state["buttonValue"] = ""
|
199 |
+
|
200 |
+
|
201 |
+
def setStartMsg(msg):
|
202 |
+
st.session_state.startMsg = msg
|
203 |
+
|
204 |
+
|
205 |
+
if "chatHistory" not in st.session_state:
|
206 |
+
st.session_state.chatHistory = []
|
207 |
+
|
208 |
+
if "messages" not in st.session_state:
|
209 |
+
st.session_state.messages = []
|
210 |
+
|
211 |
+
if "buttonValue" not in st.session_state:
|
212 |
+
resetButtonState()
|
213 |
+
|
214 |
+
if "startMsg" not in st.session_state:
|
215 |
+
st.session_state.startMsg = ""
|
216 |
+
|
217 |
+
if "emailSent" not in st.session_state:
|
218 |
+
st.session_state.emailSent = False
|
219 |
+
|
220 |
+
st.session_state.toolResponseDisplay = {}
|
221 |
+
|
222 |
+
|
223 |
+
def getMessages():
|
224 |
+
def getContextSize():
|
225 |
+
currContextSize = countTokens(SYSTEM_MSG) + countTokens(st.session_state.messages) + 150
|
226 |
+
pprint(f"{currContextSize=}")
|
227 |
+
return currContextSize
|
228 |
+
|
229 |
+
while getContextSize() > maxTokens:
|
230 |
+
pprint("Context size exceeded, removing first message")
|
231 |
+
st.session_state.messages.pop(0)
|
232 |
+
|
233 |
+
return st.session_state.messages
|
234 |
+
|
235 |
+
tools = [
|
236 |
+
toolsInfo["customerDetailsGsheets"]["schema"],
|
237 |
+
toolsInfo["saveTemplate"]["schema"],
|
238 |
+
toolsInfo["sendEmail"]["schema"],
|
239 |
+
]
|
240 |
+
|
241 |
+
def showToolResponse(toolResponseDisplay : dict):
|
242 |
+
# Directly display the message without any icon
|
243 |
+
st.markdown(toolResponseDisplay.get("text"))
|
244 |
+
|
245 |
+
def addToolCallToMsgs(toolCall: dict):
|
246 |
+
st.session_state.messages.append(
|
247 |
+
{
|
248 |
+
"role": "assistant",
|
249 |
+
"tool_calls": [
|
250 |
+
{
|
251 |
+
"id": toolCall.id,
|
252 |
+
"function": {
|
253 |
+
"name": toolCall.function.name,
|
254 |
+
"arguments": toolCall.function.arguments,
|
255 |
+
},
|
256 |
+
"type": toolCall.type,
|
257 |
+
}
|
258 |
+
],
|
259 |
+
}
|
260 |
+
)
|
261 |
+
|
262 |
+
def processToolCalls(toolCalls):
|
263 |
+
for toolCall in toolCalls:
|
264 |
+
functionName = toolCall.function.name
|
265 |
+
functionToCall = toolsInfo[functionName]["func"]
|
266 |
+
functionArgsStr = toolCall.function.arguments
|
267 |
+
pprint(f"{functionName=} | {functionArgsStr=}")
|
268 |
+
functionArgs = json.loads(functionArgsStr)
|
269 |
+
functionResult = functionToCall(**functionArgs)
|
270 |
+
functionResponse = functionResult.get("response")
|
271 |
+
responseDisplay = functionResult.get("display")
|
272 |
+
pprint(f"{functionResponse=}")
|
273 |
+
|
274 |
+
if responseDisplay:
|
275 |
+
showToolResponse(responseDisplay)
|
276 |
+
st.session_state.toolResponseDisplay = responseDisplay
|
277 |
+
|
278 |
+
addToolCallToMsgs(toolCall)
|
279 |
+
st.session_state.messages.append(
|
280 |
+
{
|
281 |
+
"role": "tool",
|
282 |
+
"tool_call_id": toolCall.id,
|
283 |
+
"name": functionName,
|
284 |
+
"content": functionResponse,
|
285 |
+
}
|
286 |
+
)
|
287 |
+
|
288 |
+
|
289 |
+
def dedupeToolCalls(toolCalls: list):
|
290 |
+
toolCallsDict = {}
|
291 |
+
for toolCall in toolCalls:
|
292 |
+
toolCallsDict[toolCall.function.name] = toolCall
|
293 |
+
dedupedToolCalls = list(toolCallsDict.values())
|
294 |
+
|
295 |
+
if len(toolCalls) != len(dedupedToolCalls):
|
296 |
+
pprint("Deduped tool calls!")
|
297 |
+
pprint(f"{toolCalls=} -> {dedupedToolCalls=}")
|
298 |
+
|
299 |
+
return dedupedToolCalls
|
300 |
+
|
301 |
+
|
302 |
+
def getRandomToolId():
|
303 |
+
return ''.join(
|
304 |
+
random.choices(
|
305 |
+
string.ascii_lowercase + string.digits,
|
306 |
+
k=4
|
307 |
+
)
|
308 |
+
)
|
309 |
+
|
310 |
+
def predict(model: str = None):
|
311 |
+
MODEL = model or model_def
|
312 |
+
|
313 |
+
messagesFormatted = [{"role": "system", "content": SYSTEM_MSG}]
|
314 |
+
messagesFormatted.extend(getMessages())
|
315 |
+
contextSize = countTokens(messagesFormatted)
|
316 |
+
pprint(f"{contextSize=} | {MODEL}")
|
317 |
+
pprint(f"{messagesFormatted=}")
|
318 |
+
|
319 |
+
response = client.chat.completions.create(
|
320 |
+
model=MODEL,
|
321 |
+
messages=messagesFormatted,
|
322 |
+
temperature=0.5,
|
323 |
+
max_tokens=4000,
|
324 |
+
stream=False,
|
325 |
+
tools=tools
|
326 |
+
)
|
327 |
+
|
328 |
+
responseMessage = response.choices[0].message
|
329 |
+
# pprint(f"{responseMessage=}")
|
330 |
+
responseContent = responseMessage.content
|
331 |
+
# pprint(f"{responseContent=}")
|
332 |
+
|
333 |
+
if responseContent and '<function=' in responseContent:
|
334 |
+
pprint("Switching to TOOLS_MODEL")
|
335 |
+
return predict(toolsModel)
|
336 |
+
|
337 |
+
|
338 |
+
if responseContent:
|
339 |
+
yield responseContent
|
340 |
+
toolCalls = responseMessage.tool_calls
|
341 |
+
# pprint(f"{toolCalls=}")
|
342 |
+
|
343 |
+
if toolCalls:
|
344 |
+
pprint(f"{toolCalls=}")
|
345 |
+
toolCalls = dedupeToolCalls(toolCalls)
|
346 |
+
try:
|
347 |
+
processToolCalls(toolCalls)
|
348 |
+
return predict()
|
349 |
+
except Exception as e:
|
350 |
+
pprint(e)
|
351 |
+
|
352 |
+
st.title("EmailGenie 📧🧞♂️")
|
353 |
+
if not (st.session_state["buttonValue"] or st.session_state["startMsg"]):
|
354 |
+
st.button(StartMsg, on_click=lambda: setStartMsg(StartMsg))
|
355 |
+
for chat in st.session_state.chatHistory:
|
356 |
+
role = chat["role"]
|
357 |
+
content = chat["content"]
|
358 |
+
toolResponseDisplay = chat.get("toolResponseDisplay")
|
359 |
+
with st.chat_message(role):
|
360 |
+
st.markdown(content)
|
361 |
+
if toolResponseDisplay:
|
362 |
+
showToolResponse(toolResponseDisplay)
|
363 |
+
|
364 |
+
|
365 |
+
if prompt := (st.chat_input() or st.session_state["buttonValue"] or st.session_state["startMsg"]):
|
366 |
+
resetButtonState()
|
367 |
+
setStartMsg("")
|
368 |
+
|
369 |
+
with st.chat_message("user"):
|
370 |
+
st.markdown(prompt)
|
371 |
+
pprint(f"{prompt=}")
|
372 |
+
st.session_state.chatHistory.append({"role": "user", "content": prompt })
|
373 |
+
st.session_state.messages.append({"role": "user", "content": prompt })
|
374 |
+
|
375 |
+
with st.chat_message("assistant"):
|
376 |
+
responseContainer = st.empty()
|
377 |
+
|
378 |
+
def __printAndGetResponse():
|
379 |
+
response = ""
|
380 |
+
# responseContainer.markdown(".....")
|
381 |
+
responseGenerator = predict()
|
382 |
+
|
383 |
+
for chunk in responseGenerator:
|
384 |
+
response += chunk
|
385 |
+
if isInvalidResponse(response):
|
386 |
+
pprint(f"Invalid_{response=}")
|
387 |
+
return
|
388 |
+
responseContainer.markdown(response)
|
389 |
+
|
390 |
+
return response
|
391 |
+
|
392 |
+
response = __printAndGetResponse()
|
393 |
+
while not response:
|
394 |
+
pprint("Empty response. Retrying..")
|
395 |
+
time.sleep(0.5)
|
396 |
+
response = __printAndGetResponse()
|
397 |
+
|
398 |
+
pprint(f"{response=}")
|
399 |
+
|
400 |
+
def selectButton(optionLabel):
|
401 |
+
st.session_state["buttonValue"] = optionLabel
|
402 |
+
pprint(f"Selected: {optionLabel}")
|
403 |
+
|
404 |
+
toolResponseDisplay = st.session_state.toolResponseDisplay
|
405 |
+
st.session_state.chatHistory.append({
|
406 |
+
"role": "assistant",
|
407 |
+
"content": response,
|
408 |
+
"toolResponseDisplay": toolResponseDisplay
|
409 |
+
})
|
410 |
+
|
411 |
+
st.session_state.messages.append({
|
412 |
+
"role": "assistant",
|
413 |
+
"content": response,
|
414 |
+
})
|
email_stats.json
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
{"date": "2024-10-06T20:49:28.154835", "requests": 1, "delivered": 1}
|
3 |
+
{"date": "2024-10-06T20:51:22.622734", "requests": 1, "delivered": 1}
|
4 |
+
{"date": "2024-10-06T20:52:55.194269", "requests": 1, "delivered": 1}
|
requirements.txt
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
streamlit
|
2 |
+
pytz
|
3 |
+
transformers
|
4 |
+
python-dotenv
|
5 |
+
gspread
|
6 |
+
google-auth
|
7 |
+
notion-client
|
8 |
+
groq
|
9 |
+
|
10 |
+
|
tools.py
ADDED
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gspread
|
2 |
+
import os
|
3 |
+
import json
|
4 |
+
import pandas
|
5 |
+
import streamlit as st
|
6 |
+
from google.oauth2.service_account import Credentials
|
7 |
+
from datetime import datetime
|
8 |
+
|
9 |
+
from dotenv import load_dotenv
|
10 |
+
load_dotenv()
|
11 |
+
|
12 |
+
app_pswd = os.environ.get("app_pswd")
|
13 |
+
sheet_id = os.environ.get("sheet_id")
|
14 |
+
|
15 |
+
|
16 |
+
email_add = "[email protected]"
|
17 |
+
my_name = "harsh sethi"
|
18 |
+
template_col_name = "template"
|
19 |
+
|
20 |
+
def customerDetailsGsheets( objective,personal_details,industry,recipientRole,recipientName):
|
21 |
+
scopes = [
|
22 |
+
"https://www.googleapis.com/auth/spreadsheets"
|
23 |
+
]
|
24 |
+
creds = Credentials.from_service_account_file("credentials.json", scopes= scopes)
|
25 |
+
client = gspread.authorize(creds)
|
26 |
+
try:
|
27 |
+
workbook = client.open_by_key(sheet_id)
|
28 |
+
except gspread.SpreadsheetNotFound:
|
29 |
+
return {"response": "Spreadsheet not found.", "status": "error"}
|
30 |
+
|
31 |
+
# Select the specific worksheet (tab) where you want to append the data
|
32 |
+
try:
|
33 |
+
sheet = workbook.worksheet("Sheet1") # Add the worksheet name here
|
34 |
+
except gspread.WorksheetNotFound:
|
35 |
+
return {"response": "Worksheet not found.", "status": "error"}
|
36 |
+
|
37 |
+
# Append the customer details as a new row in the worksheet
|
38 |
+
if not (objective and personal_details):
|
39 |
+
return{
|
40 |
+
"response" : "please enter the important details"
|
41 |
+
}
|
42 |
+
|
43 |
+
|
44 |
+
try:
|
45 |
+
sheet.append_row([objective, personal_details, industry, recipientRole, recipientName])
|
46 |
+
return {
|
47 |
+
"response": "Saved customer details in Google Sheet.",
|
48 |
+
"display": {
|
49 |
+
"text": f"Customer details saved in Google Sheet: [Link]({workbook.url})"
|
50 |
+
},
|
51 |
+
"status": "success"
|
52 |
+
}
|
53 |
+
except Exception as e:
|
54 |
+
return {"response": f"Error saving data: {str(e)}", "status": "error"}
|
55 |
+
|
56 |
+
|
57 |
+
def saveTemplate(template:str):
|
58 |
+
scopes = [
|
59 |
+
"https://www.googleapis.com/auth/spreadsheets"
|
60 |
+
]
|
61 |
+
|
62 |
+
# Authorize using the service account credentials
|
63 |
+
creds = Credentials.from_service_account_file("credentials.json", scopes=scopes)
|
64 |
+
client = gspread.authorize(creds)
|
65 |
+
|
66 |
+
# Open the workbook using the spreadsheet ID
|
67 |
+
workbook = client.open_by_key(sheet_id)
|
68 |
+
|
69 |
+
# Try to access the specific worksheet
|
70 |
+
try:
|
71 |
+
sheet = workbook.worksheet("Sheet1")
|
72 |
+
except gspread.WorksheetNotFound:
|
73 |
+
print(f"Worksheet 'Sheet1' not found.")
|
74 |
+
return {
|
75 |
+
"response": "Failed to save template. Worksheet not found.",
|
76 |
+
"display": {
|
77 |
+
"text": "Failed to save template. Worksheet not found."
|
78 |
+
},
|
79 |
+
"status": "error"
|
80 |
+
}
|
81 |
+
|
82 |
+
# Get the headers to locate the template column
|
83 |
+
headers = sheet.row_values(1)
|
84 |
+
try:
|
85 |
+
templateColIndex = headers.index(template_col_name) + 1 # Replace with your actual column name
|
86 |
+
except ValueError:
|
87 |
+
print("Template column not found in the headers.")
|
88 |
+
return {
|
89 |
+
"response": "Failed to save template. Template column not found.",
|
90 |
+
"display": {
|
91 |
+
"text": "Failed to save template. Template column not found."
|
92 |
+
},
|
93 |
+
"status": "error"
|
94 |
+
}
|
95 |
+
|
96 |
+
# Find the last available row to insert the template
|
97 |
+
lastRow = len([row for row in sheet.get_all_values() if any(row)])
|
98 |
+
|
99 |
+
# Update the cell with the template in the correct column
|
100 |
+
sheet.update_cell(lastRow, templateColIndex, template)
|
101 |
+
|
102 |
+
# Return success response
|
103 |
+
return {
|
104 |
+
"response": "Saved template in Google Sheet.",
|
105 |
+
"display": {
|
106 |
+
"text": f"Template saved successfully in Google Sheet: [Link]({workbook.url})"
|
107 |
+
},
|
108 |
+
"status": "success"
|
109 |
+
}
|
110 |
+
|
111 |
+
|
112 |
+
def sendEmail(recipient_mail,mail_body,mail_subject):
|
113 |
+
import smtplib
|
114 |
+
from email.mime.multipart import MIMEMultipart
|
115 |
+
from email.mime.text import MIMEText
|
116 |
+
|
117 |
+
# Your Gmail credentials
|
118 |
+
sender_email = email_add
|
119 |
+
password = app_pswd
|
120 |
+
receiver_email = recipient_mail
|
121 |
+
|
122 |
+
# Set up the server using Gmail's SMTP server
|
123 |
+
server = smtplib.SMTP('smtp.gmail.com', 587)
|
124 |
+
server.starttls() # Secure the connection
|
125 |
+
server.login(sender_email, password)
|
126 |
+
|
127 |
+
# Create email
|
128 |
+
msg = MIMEMultipart()
|
129 |
+
msg['From'] = sender_email
|
130 |
+
msg['To'] = receiver_email
|
131 |
+
msg['Subject'] = mail_subject
|
132 |
+
|
133 |
+
# Email body
|
134 |
+
body = mail_body
|
135 |
+
msg.attach(MIMEText(body, 'plain'))
|
136 |
+
|
137 |
+
# Send the email
|
138 |
+
try:
|
139 |
+
server.sendmail(sender_email, receiver_email, msg.as_string())
|
140 |
+
# Log email stats
|
141 |
+
email_status = {
|
142 |
+
'date': datetime.now().isoformat(),
|
143 |
+
'requests': 1,
|
144 |
+
'delivered': 1 # Assuming delivery was successful
|
145 |
+
}
|
146 |
+
with open('email_stats.json', 'a') as f:
|
147 |
+
json.dump(email_status, f)
|
148 |
+
f.write('\n') # New line for each log entry
|
149 |
+
|
150 |
+
# Disconnect from the server
|
151 |
+
server.quit()
|
152 |
+
|
153 |
+
print("Email sent successfully!")
|
154 |
+
return {
|
155 |
+
"response": "Email sent",
|
156 |
+
"display": {
|
157 |
+
"text": f"Email sent to {recipient_mail}"
|
158 |
+
}
|
159 |
+
}
|
160 |
+
except Exception as e:
|
161 |
+
print(e.message)
|
162 |
+
server.quit()
|
163 |
+
return {
|
164 |
+
"response": f"Failed to send email. Error: {e.message}",
|
165 |
+
"display": {
|
166 |
+
"text": f"Failed to send email to {recipient_mail}"
|
167 |
+
}
|
168 |
+
}
|
169 |
+
|
170 |
+
|
171 |
+
toolsInfo = {
|
172 |
+
"customerDetailsGsheets": {
|
173 |
+
"func": customerDetailsGsheets,
|
174 |
+
"schema": {
|
175 |
+
"type": "function",
|
176 |
+
"function": {
|
177 |
+
"name": "customerDetailsGsheets",
|
178 |
+
"description": "Saves customer details in Google Sheets",
|
179 |
+
"parameters": {
|
180 |
+
"type": "object",
|
181 |
+
"properties": {
|
182 |
+
"objective": {
|
183 |
+
"type": "string",
|
184 |
+
"description": "Objective or purpose of the email"
|
185 |
+
},
|
186 |
+
"personal_details": {
|
187 |
+
"type": "string",
|
188 |
+
"description": "Personal details about the sender"
|
189 |
+
},
|
190 |
+
"industry": {
|
191 |
+
"type": "string",
|
192 |
+
"description": "Recipient's industry"
|
193 |
+
},
|
194 |
+
"recipientRole": {
|
195 |
+
"type": "string",
|
196 |
+
"description": "Recipient's role in the company"
|
197 |
+
},
|
198 |
+
"recipientName": {
|
199 |
+
"type": "string",
|
200 |
+
"description": "Name of the recipient"
|
201 |
+
}
|
202 |
+
},
|
203 |
+
"required": ["objective", "personal_details", "industry", "recipientRole", "recipientName"]
|
204 |
+
}
|
205 |
+
}
|
206 |
+
},
|
207 |
+
},
|
208 |
+
|
209 |
+
"saveTemplate": {
|
210 |
+
"func": saveTemplate,
|
211 |
+
"schema": {
|
212 |
+
"type": "function",
|
213 |
+
"function": {
|
214 |
+
"name": "saveTemplate",
|
215 |
+
"description": "Saves the email template in Google Sheets",
|
216 |
+
"parameters": {
|
217 |
+
"type": "object",
|
218 |
+
"properties": {
|
219 |
+
"template": {
|
220 |
+
"type": "string",
|
221 |
+
"description": "Email template to save"
|
222 |
+
}
|
223 |
+
},
|
224 |
+
"required": ["template"]
|
225 |
+
}
|
226 |
+
}
|
227 |
+
},
|
228 |
+
},
|
229 |
+
|
230 |
+
"sendEmail": {
|
231 |
+
"func": sendEmail,
|
232 |
+
"schema": {
|
233 |
+
"type": "function",
|
234 |
+
"function": {
|
235 |
+
"name": "sendEmail",
|
236 |
+
"description": "Sends an email to the recipient",
|
237 |
+
"parameters": {
|
238 |
+
"type": "object",
|
239 |
+
"properties": {
|
240 |
+
"recipient_mail": {
|
241 |
+
"type": "string",
|
242 |
+
"description": "Email address of the recipient"
|
243 |
+
},
|
244 |
+
"mail_body": {
|
245 |
+
"type": "string",
|
246 |
+
"description": "Body content of the email"
|
247 |
+
},
|
248 |
+
"mail_subject": {
|
249 |
+
"type": "string",
|
250 |
+
"description": "Subject of the email"
|
251 |
+
}
|
252 |
+
},
|
253 |
+
"required": ["recipient_mail", "mail_body", "mail_subject"]
|
254 |
+
}
|
255 |
+
}
|
256 |
+
},
|
257 |
+
},
|
258 |
+
}
|
259 |
+
|
260 |
+
|
261 |
+
|
262 |
+
|