Compare commits

...

14 Commits

7 changed files with 108 additions and 26 deletions
+42 -1
View File
@@ -4,9 +4,11 @@ ChatMastermind is a Python application that automates conversation with AI, stor
The project uses the OpenAI API to generate responses and stores the data in YAML files. It also allows you to filter chat history based on tags and supports autocompletion for tags. The project uses the OpenAI API to generate responses and stores the data in YAML files. It also allows you to filter chat history based on tags and supports autocompletion for tags.
Official repository URL: https://kaizenkodo.no/gitea/kaizenkodo/ChatMastermind.git
## Requirements ## Requirements
- Python 3.6 or higher - Python 3.9 or higher
- openai - openai
- PyYAML - PyYAML
- argcomplete - argcomplete
@@ -113,6 +115,45 @@ eval "$(register-python-argcomplete cmm)"
After adding this line, restart your shell or run `source <your-shell-config-file>` to enable autocompletion for the `cmm` script. After adding this line, restart your shell or run `source <your-shell-config-file>` to enable autocompletion for the `cmm` script.
## Contributing
### Enable commit hooks
```
pip install pre-commit
pre-commit install
```
### Execute tests before opening a PR
```
pytest
```
### Consider using `pyenv` / `pyenv-virtualenv`
Short installation instructions:
* install `pyenv`:
```
cd ~
git clone https://github.com/pyenv/pyenv .pyenv
cd ~/.pyenv && src/configure && make -C src
```
* make sure that `~/.pyenv/shims` and `~/.pyenv/bin` are the first entries in your `PATH`, e. g. by setting it in `~/.bashrc`
* add the following to your `~/.bashrc` (after setting `PATH`): `eval "$(pyenv init -)"`
* create a new terminal or source the changes (e. g. `source ~/.bashrc`)
* install `virtualenv`
```
git clone https://github.com/pyenv/pyenv-virtualenv.git $(pyenv root)/plugins/pyenv-virtualenv
```
* add the following to your `~/.bashrc` (after the commands above): `eval "$(pyenv virtualenv-init -)`
* create a new terminal or source the changes (e. g. `source ~/.bashrc`)
* go back to the `ChatMasterMind` repo and create a virtual environment with the latest `Python`, e. g. `3.11.4`:
```
cd <CMM_REPO_PATH>
pyenv install 3.11.4
pyenv virtualenv 3.11.4 py311
pyenv activate py311
```
* see also the [official pyenv documentation](https://github.com/pyenv/pyenv#readme)
## License ## License
This project is licensed under the terms of the WTFPL License. This project is licensed under the terms of the WTFPL License.
+11
View File
@@ -5,6 +5,17 @@ def openai_api_key(api_key: str) -> None:
openai.api_key = api_key openai.api_key = api_key
def display_models() -> None:
not_ready = []
for engine in sorted(openai.Engine.list()['data'], key=lambda x: x['id']):
if engine['ready']:
print(engine['id'])
else:
not_ready.append(engine['id'])
if len(not_ready) > 0:
print('\nNot ready: ' + ', '.join(not_ready))
def ai(chat: list[dict[str, str]], def ai(chat: list[dict[str, str]],
config: dict, config: dict,
number: int number: int
+11 -3
View File
@@ -9,7 +9,7 @@ import argparse
import pathlib import pathlib
from .utils import terminal_width, process_tags, display_chat, display_source_code, display_tags_frequency from .utils import terminal_width, process_tags, display_chat, display_source_code, display_tags_frequency
from .storage import save_answers, create_chat, get_tags, get_tags_unique, read_file, dump_data from .storage import save_answers, create_chat, get_tags, get_tags_unique, read_file, dump_data
from .api_client import ai, openai_api_key from .api_client import ai, openai_api_key, display_models
from itertools import zip_longest from itertools import zip_longest
@@ -61,6 +61,7 @@ def process_and_display_chat(args: argparse.Namespace,
display_chat(chat, dump, args.only_source_code) display_chat(chat, dump, args.only_source_code)
return chat, full_question, tags return chat, full_question, tags
def process_and_display_tags(args: argparse.Namespace, def process_and_display_tags(args: argparse.Namespace,
config: dict, config: dict,
dump: bool = False dump: bool = False
@@ -96,6 +97,7 @@ def create_parser() -> argparse.ArgumentParser:
group.add_argument('-D', '--chat-dump', help="Print chat history as Python structure", action='store_true') group.add_argument('-D', '--chat-dump', help="Print chat history as Python structure", action='store_true')
group.add_argument('-d', '--chat', help="Print chat history as readable text", action='store_true') group.add_argument('-d', '--chat', help="Print chat history as readable text", action='store_true')
group.add_argument('-l', '--list-tags', help="List all tags and their frequency", action='store_true') group.add_argument('-l', '--list-tags', help="List all tags and their frequency", action='store_true')
group.add_argument('-L', '--list-models', help="List all available models", action='store_true')
parser.add_argument('-c', '--config', help='Config file name.', default=default_config) parser.add_argument('-c', '--config', help='Config file name.', default=default_config)
parser.add_argument('-m', '--max-tokens', help='Max tokens to use', type=int) parser.add_argument('-m', '--max-tokens', help='Max tokens to use', type=int)
parser.add_argument('-T', '--temperature', help='Temperature to use', type=float) parser.add_argument('-T', '--temperature', help='Temperature to use', type=float)
@@ -104,8 +106,12 @@ def create_parser() -> argparse.ArgumentParser:
parser.add_argument('-s', '--source', nargs='*', help='Source add content of a file to the query') parser.add_argument('-s', '--source', nargs='*', help='Source add content of a file to the query')
parser.add_argument('-S', '--only-source-code', help='Print only source code', action='store_true') parser.add_argument('-S', '--only-source-code', help='Print only source code', action='store_true')
parser.add_argument('-w', '--with-tags', help="Print chat history with tags.", action='store_true') parser.add_argument('-w', '--with-tags', help="Print chat history with tags.", action='store_true')
parser.add_argument('-W', '--with-file', help="Print chat history with filename.", action='store_true') parser.add_argument('-W', '--with-file',
parser.add_argument('-a', '--match-all-tags', help="All given tags must match when selecting chat history entries.", action='store_true') help="Print chat history with filename.",
action='store_true')
parser.add_argument('-a', '--match-all-tags',
help="All given tags must match when selecting chat history entries.",
action='store_true')
tags_arg = parser.add_argument('-t', '--tags', nargs='*', help='List of tag names', metavar='TAGS') tags_arg = parser.add_argument('-t', '--tags', nargs='*', help='List of tag names', metavar='TAGS')
tags_arg.completer = tags_completer # type: ignore tags_arg.completer = tags_completer # type: ignore
extags_arg = parser.add_argument('-e', '--extags', nargs='*', help='List of tag names to exclude', metavar='EXTAGS') extags_arg = parser.add_argument('-e', '--extags', nargs='*', help='List of tag names to exclude', metavar='EXTAGS')
@@ -144,6 +150,8 @@ def main() -> int:
process_and_display_chat(args, config) process_and_display_chat(args, config)
elif args.list_tags: elif args.list_tags:
process_and_display_tags(args, config) process_and_display_tags(args, config)
elif args.list_models:
display_models()
return 0 return 0
+11 -6
View File
@@ -5,23 +5,26 @@ from .utils import terminal_width, append_message, message_to_chat
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
def read_file(fname: str, tags_only: bool = False) -> Dict[str, Any]: def read_file(fname: pathlib.Path, tags_only: bool = False) -> Dict[str, Any]:
with open(fname, "r") as fd: with open(fname, "r") as fd:
tagline = fd.readline().strip().split(':', maxsplit=1)[1].strip()
# also support tags separated by ',' (old format)
separator = ',' if ',' in tagline else ' '
tags = [t.strip() for t in tagline.split(separator)]
if tags_only: if tags_only:
return {"tags": [x.strip() for x in fd.readline().strip().split(':')[1].strip().split(',')]} return {"tags": tags}
text = fd.read().strip().split('\n') text = fd.read().strip().split('\n')
tags = [x.strip() for x in text.pop(0).split(':')[1].strip().split(',')]
question_idx = text.index("=== QUESTION ===") + 1 question_idx = text.index("=== QUESTION ===") + 1
answer_idx = text.index("==== ANSWER ====") answer_idx = text.index("==== ANSWER ====")
question = "\n".join(text[question_idx:answer_idx]).strip() question = "\n".join(text[question_idx:answer_idx]).strip()
answer = "\n".join(text[answer_idx + 1:]).strip() answer = "\n".join(text[answer_idx + 1:]).strip()
return {"question": question, "answer": answer, "tags": tags, return {"question": question, "answer": answer, "tags": tags,
"file": pathlib.Path(fname).name} "file": fname.name}
def dump_data(data: Dict[str, Any]) -> str: def dump_data(data: Dict[str, Any]) -> str:
with io.StringIO() as fd: with io.StringIO() as fd:
fd.write(f'TAGS: {", ".join(data["tags"])}\n') fd.write(f'TAGS: {" ".join(data["tags"])}\n')
fd.write(f'=== QUESTION ===\n{data["question"]}\n') fd.write(f'=== QUESTION ===\n{data["question"]}\n')
fd.write(f'==== ANSWER ====\n{data["answer"]}\n') fd.write(f'==== ANSWER ====\n{data["answer"]}\n')
return fd.getvalue() return fd.getvalue()
@@ -29,7 +32,7 @@ def dump_data(data: Dict[str, Any]) -> str:
def write_file(fname: str, data: Dict[str, Any]) -> None: def write_file(fname: str, data: Dict[str, Any]) -> None:
with open(fname, "w") as fd: with open(fname, "w") as fd:
fd.write(f'TAGS: {", ".join(data["tags"])}\n') fd.write(f'TAGS: {" ".join(data["tags"])}\n')
fd.write(f'=== QUESTION ===\n{data["question"]}\n') fd.write(f'=== QUESTION ===\n{data["question"]}\n')
fd.write(f'==== ANSWER ====\n{data["answer"]}\n') fd.write(f'==== ANSWER ====\n{data["answer"]}\n')
@@ -74,6 +77,7 @@ def create_chat(question: Optional[str],
if file.suffix == '.yaml': if file.suffix == '.yaml':
with open(file, 'r') as f: with open(file, 'r') as f:
data = yaml.load(f, Loader=yaml.FullLoader) data = yaml.load(f, Loader=yaml.FullLoader)
data['file'] = file.name
elif file.suffix == '.txt': elif file.suffix == '.txt':
data = read_file(file) data = read_file(file)
else: else:
@@ -111,5 +115,6 @@ def get_tags(config: Dict[str, Any], prefix: Optional[str]) -> List[str]:
result.append(tag) result.append(tag)
return result return result
def get_tags_unique(config: Dict[str, Any], prefix: Optional[str]) -> List[str]: def get_tags_unique(config: Dict[str, Any], prefix: Optional[str]) -> List[str]:
return list(set(get_tags(config, prefix))) return list(set(get_tags(config, prefix)))
+6 -5
View File
@@ -15,11 +15,11 @@ def process_tags(tags: list[str], extags: list[str], otags: list[str]) -> None:
printed_messages = [] printed_messages = []
if tags: if tags:
printed_messages.append(f"Tags: {', '.join(tags)}") printed_messages.append(f"Tags: {' '.join(tags)}")
if extags: if extags:
printed_messages.append(f"Excluding tags: {', '.join(extags)}") printed_messages.append(f"Excluding tags: {' '.join(extags)}")
if otags: if otags:
printed_messages.append(f"Output tags: {', '.join(otags)}") printed_messages.append(f"Output tags: {' '.join(otags)}")
if printed_messages: if printed_messages:
print("\n".join(printed_messages)) print("\n".join(printed_messages))
@@ -41,7 +41,7 @@ def message_to_chat(message: Dict[str, str],
append_message(chat, 'user', message['question']) append_message(chat, 'user', message['question'])
append_message(chat, 'assistant', message['answer']) append_message(chat, 'assistant', message['answer'])
if with_tags: if with_tags:
tags = ", ".join(message['tags']) tags = " ".join(message['tags'])
append_message(chat, 'tags', tags) append_message(chat, 'tags', tags)
if with_file: if with_file:
append_message(chat, 'file', message['file']) append_message(chat, 'file', message['file'])
@@ -74,9 +74,10 @@ def display_chat(chat, dump=False, source_code=False) -> None:
else: else:
print(f"{message['role'].upper()}: {message['content']}") print(f"{message['role'].upper()}: {message['content']}")
def display_tags_frequency(tags: List[str], dump=False) -> None: def display_tags_frequency(tags: List[str], dump=False) -> None:
if dump: if dump:
pp(tags) pp(tags)
return return
for tag in set(tags): for tag in sorted(set(tags)):
print(f"- {tag}: {tags.count(tag)}") print(f"- {tag}: {tags.count(tag)}")
+8 -2
View File
@@ -15,12 +15,18 @@ setup(
packages=find_packages(), packages=find_packages(),
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "Intended Audience :: End Users/Desktop",
"Intended Audience :: Science/Research",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
"Topic :: Text Processing",
], ],
install_requires=[ install_requires=[
"openai", "openai",
@@ -28,7 +34,7 @@ setup(
"argcomplete", "argcomplete",
"pytest" "pytest"
], ],
python_requires=">=3.10", python_requires=">=3.9",
test_suite="tests", test_suite="tests",
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
+17 -7
View File
@@ -21,9 +21,11 @@ class TestCreateChat(unittest.TestCase):
self.tags = ['test_tag'] self.tags = ['test_tag']
@patch('os.listdir') @patch('os.listdir')
@patch('pathlib.Path.iterdir')
@patch('builtins.open') @patch('builtins.open')
def test_create_chat_with_tags(self, open_mock, listdir_mock): def test_create_chat_with_tags(self, open_mock, iterdir_mock, listdir_mock):
listdir_mock.return_value = ['testfile.txt'] listdir_mock.return_value = ['testfile.txt']
iterdir_mock.return_value = [pathlib.Path(x) for x in listdir_mock.return_value]
open_mock.return_value.__enter__.return_value = io.StringIO(dump_data( open_mock.return_value.__enter__.return_value = io.StringIO(dump_data(
{'question': 'test_content', 'answer': 'some answer', {'question': 'test_content', 'answer': 'some answer',
'tags': ['test_tag']})) 'tags': ['test_tag']}))
@@ -41,9 +43,11 @@ class TestCreateChat(unittest.TestCase):
{'role': 'user', 'content': self.question}) {'role': 'user', 'content': self.question})
@patch('os.listdir') @patch('os.listdir')
@patch('pathlib.Path.iterdir')
@patch('builtins.open') @patch('builtins.open')
def test_create_chat_with_other_tags(self, open_mock, listdir_mock): def test_create_chat_with_other_tags(self, open_mock, iterdir_mock, listdir_mock):
listdir_mock.return_value = ['testfile.txt'] listdir_mock.return_value = ['testfile.txt']
iterdir_mock.return_value = [pathlib.Path(x) for x in listdir_mock.return_value]
open_mock.return_value.__enter__.return_value = io.StringIO(dump_data( open_mock.return_value.__enter__.return_value = io.StringIO(dump_data(
{'question': 'test_content', 'answer': 'some answer', {'question': 'test_content', 'answer': 'some answer',
'tags': ['other_tag']})) 'tags': ['other_tag']}))
@@ -57,9 +61,11 @@ class TestCreateChat(unittest.TestCase):
{'role': 'user', 'content': self.question}) {'role': 'user', 'content': self.question})
@patch('os.listdir') @patch('os.listdir')
@patch('pathlib.Path.iterdir')
@patch('builtins.open') @patch('builtins.open')
def test_create_chat_without_tags(self, open_mock, listdir_mock): def test_create_chat_without_tags(self, open_mock, iterdir_mock, listdir_mock):
listdir_mock.return_value = ['testfile.txt', 'testfile2.txt'] listdir_mock.return_value = ['testfile.txt', 'testfile2.txt']
iterdir_mock.return_value = [pathlib.Path(x) for x in listdir_mock.return_value]
open_mock.side_effect = ( open_mock.side_effect = (
io.StringIO(dump_data({'question': 'test_content', io.StringIO(dump_data({'question': 'test_content',
'answer': 'some answer', 'answer': 'some answer',
@@ -95,7 +101,10 @@ class TestHandleQuestion(unittest.TestCase):
question=[self.question], question=[self.question],
source=None, source=None,
only_source_code=False, only_source_code=False,
number=3 number=3,
match_all_tags=False,
with_tags=False,
with_file=False,
) )
self.config = { self.config = {
'db': 'test_files', 'db': 'test_files',
@@ -119,7 +128,8 @@ class TestHandleQuestion(unittest.TestCase):
mock_create_chat.assert_called_once_with(self.question, mock_create_chat.assert_called_once_with(self.question,
self.args.tags, self.args.tags,
self.args.extags, self.args.extags,
self.config) self.config,
False, False, False)
mock_pp.assert_called_once_with("test_chat") mock_pp.assert_called_once_with("test_chat")
mock_ai.assert_called_with("test_chat", mock_ai.assert_called_with("test_chat",
self.config, self.config,
@@ -203,7 +213,7 @@ class TestCreateParser(unittest.TestCase):
mock_add_mutually_exclusive_group.assert_called_once_with(required=True) mock_add_mutually_exclusive_group.assert_called_once_with(required=True)
mock_group.add_argument.assert_any_call('-p', '--print', help='File to print') mock_group.add_argument.assert_any_call('-p', '--print', help='File to print')
mock_group.add_argument.assert_any_call('-q', '--question', nargs='*', help='Question to ask') mock_group.add_argument.assert_any_call('-q', '--question', nargs='*', help='Question to ask')
mock_group.add_argument.assert_any_call('-D', '--chat-dump', help="Print chat as Python structure", action='store_true') mock_group.add_argument.assert_any_call('-D', '--chat-dump', help="Print chat history as Python structure", action='store_true')
mock_group.add_argument.assert_any_call('-d', '--chat', help="Print chat as readable text", action='store_true') mock_group.add_argument.assert_any_call('-d', '--chat', help="Print chat history as readable text", action='store_true')
self.assertTrue('.config.yaml' in parser.get_default('config')) self.assertTrue('.config.yaml' in parser.get_default('config'))
self.assertEqual(parser.get_default('number'), 1) self.assertEqual(parser.get_default('number'), 1)