Arcadia822 commited on
Commit
2fe4e9d
·
unverified ·
1 Parent(s): 40a9b82

feat(qa): :sparkles: normal qa (#9)

Browse files
.github/workflows/test.yml CHANGED
@@ -27,11 +27,11 @@ jobs:
27
  poetry-version: '1.5.1'
28
 
29
  - name: Install Dependencies
30
- run: poetry install
31
 
32
  - name: Run Test
33
  run:
34
- poetry run pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=codedog test/ | tee pytest-coverage.txt
35
 
36
  - name: Pytest coverage comment
37
  id: coverageComment
 
27
  poetry-version: '1.5.1'
28
 
29
  - name: Install Dependencies
30
+ run: poetry install --with dev
31
 
32
  - name: Run Test
33
  run:
34
+ poetry run pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=edu_assistant tests/ | tee pytest-coverage.txt
35
 
36
  - name: Pytest coverage comment
37
  id: coverageComment
README.md CHANGED
@@ -3,3 +3,24 @@
3
  [![Checkstyle](https://github.com/Arcadia822/codedog/actions/workflows/flake8.yml/badge.svg)](https://github.com/codedog-ai/edu-assistant/actions/workflows/flake8.yml)
4
  [![Pytest](https://github.com/Arcadia822/codedog/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/codedog-ai/edu-assistant/actions/workflows/test.yml)
5
  [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Arcadia822/ce38dae58995aeffef42065093fcfe84/raw/edu_assistant_master.json)](https://github.com/codedog-ai/edu-assistant/actions/workflows/test.yml)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  [![Checkstyle](https://github.com/Arcadia822/codedog/actions/workflows/flake8.yml/badge.svg)](https://github.com/codedog-ai/edu-assistant/actions/workflows/flake8.yml)
4
  [![Pytest](https://github.com/Arcadia822/codedog/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/codedog-ai/edu-assistant/actions/workflows/test.yml)
5
  [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Arcadia822/ce38dae58995aeffef42065093fcfe84/raw/edu_assistant_master.json)](https://github.com/codedog-ai/edu-assistant/actions/workflows/test.yml)
6
+
7
+ ## config
8
+
9
+ | Environment Variable | Necessary | Default | Description |
10
+ |---|---|---|---|
11
+ | CODEDOG_SERVER | No | 0.0.0.0 | Server address |
12
+ | CODEDOG_PORT | No | 32167 | Server port |
13
+ | CODEDOG_WORKER_NUM | No | 1 | Server worker number |
14
+ | OPENAI_API_KEY | Yes | | Api Key for calling openai api |
15
+ | OPENAI_PROXY | No | | openai proxy |
16
+ | AZURE_OPENAI | No | | use azure openai if not blank |
17
+ | AZURE_OPENAI_API_KEY | No | | azure openai api key |
18
+ | AZURE_OPENAI_API_BASE | No | | azure openai api base |
19
+ | AZURE_OPENAI_DEPLOYMENT_ID | No | | azure openai deployment id for gpt 3.5 |
20
+ | AZURE_OPENAI_EMBEDDING_DEP_ID | No | | azure openai deployment id for embedding |
21
+
22
+ ## setup
23
+
24
+ - install python 3.10+
25
+ - install poetry 1.5.1+
26
+ - run: `poetry install`
edu_assistant/__init__.py ADDED
File without changes
edu_assistant/learning_cases/__init__.py ADDED
File without changes
edu_assistant/learning_cases/base.py ADDED
File without changes
edu_assistant/learning_cases/context.py ADDED
File without changes
edu_assistant/learning_tasks/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .qa import QaTask
2
+
3
+ __all__ = ["QaTask"]
edu_assistant/learning_tasks/base.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ class BaseTask:
2
+ pass
edu_assistant/learning_tasks/qa.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import sys
3
+
4
+ from langchain import PromptTemplate
5
+ from langchain.chains import ConversationalRetrievalChain, ConversationChain
6
+ from langchain.chains.base import Chain
7
+ from langchain.memory import ConversationBufferMemory
8
+ from langchain.schema import BaseRetriever
9
+
10
+ from edu_assistant.learning_tasks.base import BaseTask
11
+ from edu_assistant.utils.langchain_utils import load_llm, update_chat_memory
12
+
13
+ TEMPLATE = """{instruction}
14
+ The following is a friendly conversation between a human and an AI.
15
+ The AI is talkative and provides lots of specific details from its context.
16
+ If the AI does not know the answer to a question, it truthfully says it does not know.
17
+
18
+ Current conversation:
19
+ {{history}}
20
+ Human: {{input}}
21
+ AI:"""
22
+
23
+
24
+ class QaTask(BaseTask):
25
+ _qa: Chain
26
+ _session_store: dict
27
+
28
+ def __init__(
29
+ self,
30
+ instruction: str = "",
31
+ knowledge: BaseRetriever = None,
32
+ session_store: dict[int, ConversationBufferMemory] = None,
33
+ ):
34
+ """Create a new QaTask service.
35
+
36
+ Args:
37
+ instruction (str, optional): Instruction for this task ai. Defaults to "".
38
+ knowledge (BaseRetriever, optional): Answer question with this knowledge retriever.
39
+ If not set, will not use knowledge to answer question.
40
+ Defaults to None.
41
+ session_store (dict, optional): External chat history store. Defaults to None.
42
+ If not set, will use internal memory to store chat history. Which will be lost after restart and might
43
+ cost huge memory.
44
+ """
45
+ self._prompt = TEMPLATE.format(instruction=instruction)
46
+ self._qa = self._build_chain(knowledge)
47
+ self._session_store = {} if not session_store else session_store
48
+
49
+ def _build_chain(self, knowledge):
50
+ if not knowledge:
51
+ return ConversationChain(llm=load_llm(), prompt=PromptTemplate.from_template(self._prompt))
52
+ else:
53
+ return ConversationalRetrievalChain.from_llm(
54
+ llm=load_llm(),
55
+ retriever=knowledge,
56
+ condense_question_llm=load_llm(),
57
+ return_source_documents=True,
58
+ combine_docs_chain_kwargs={"prompt": PromptTemplate.from_template(self._prompt)},
59
+ )
60
+
61
+ def ask(self, question: str, session: bool = True, session_id: int = None, session_mem: list | None = None) -> dict:
62
+ """ask a question with chat history.
63
+
64
+ Args:
65
+ question (str): question to llm.
66
+ session (bool, optional): whether use and store chat history. Defaults to False.
67
+ if session_id is not set, a new session will be created.
68
+ session_id (int, optional): specify a history session. Defaults to None.
69
+ session_mem (list | None, optional): specify chat history. Defaults to None.
70
+ if session_id is also set, chat history will be fully replaced.
71
+
72
+ Returns:
73
+ dict: question answer and metadata.
74
+ contains answer.
75
+ contains session_id if session is True.
76
+ """
77
+
78
+ if session:
79
+ session_id = session_id or self._create_session_id()
80
+ memory = session_mem or self._get_session_mem(session_id)
81
+
82
+ result = self._ask(question, memory)
83
+
84
+ update_chat_memory(memory, question, result["response"])
85
+ self._update_session_mem(session_id, memory)
86
+ else:
87
+ result = self._ask(question)
88
+
89
+ if session_id:
90
+ result["session_id"] = session_id
91
+
92
+ return result
93
+
94
+ def _ask(self, question, memory: ConversationBufferMemory = None) -> dict:
95
+ if memory is None:
96
+ return self._qa({"input": question, "history": ""})
97
+ else:
98
+ return self._qa({"input": question, "history": memory.chat_memory})
99
+
100
+ def _get_session_mem(self, session_id):
101
+ if session_id not in self._session_store:
102
+ memory = self._init_memory(session_id)
103
+ else:
104
+ memory = self._session_store.get(session_id)
105
+
106
+ return memory
107
+
108
+ def _update_session_mem(self, session_id, memory):
109
+ self._session_store[session_id] = memory
110
+
111
+ def _create_session_id(self):
112
+ return random.randint(1, sys.maxsize)
113
+
114
+ def _init_memory(self, session_id) -> ConversationBufferMemory:
115
+ # TODO: redis memory store
116
+ return ConversationBufferMemory(memory_key="chat_history", return_messages=True, output_key="answer")
edu_assistant/utils/__init__.py ADDED
File without changes
edu_assistant/utils/common_utils.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utility functions for codedog
3
+ """
4
+ import hashlib
5
+ import logging
6
+ import time
7
+ from logging.config import dictConfig
8
+
9
+ # -- Logging ------------------------------------------------------------------
10
+
11
+
12
+ def init_local_logging(level=logging.INFO):
13
+ """setup logging interface for local debugging"""
14
+ dictConfig(get_logging_config(level))
15
+
16
+
17
+ def get_logging_config(level=logging.INFO):
18
+ return {
19
+ "version": 1,
20
+ "disable_existing_loggers": False,
21
+ "formatters": {
22
+ "plain": {
23
+ "format": "[%(asctime)s][%(levelname)s][%(pathname)s:%(lineno)d][%(name)s]%(message)s",
24
+ "datefmt": "%Y-%m-%d %H:%M:%S",
25
+ },
26
+ },
27
+ "handlers": {
28
+ "console_handler": {
29
+ "level": "DEBUG",
30
+ "formatter": "plain",
31
+ "class": "logging.StreamHandler",
32
+ "stream": "ext://sys.stdout", # Default is stderr
33
+ },
34
+ },
35
+ "loggers": {
36
+ "": {
37
+ "level": level,
38
+ "handlers": ["console_handler"],
39
+ },
40
+ },
41
+ }
42
+
43
+
44
+ # -- exception ----------------------------------------------------------------
45
+
46
+
47
+ class CodedogError(Exception):
48
+ def __init__(self, message: str = None, code: int = -1):
49
+ self.message = "" if not message else str(message)
50
+ self.code = code
51
+
52
+
53
+ # -- utility ------------------------------------------------------------------
54
+
55
+
56
+ def get_ttl_hash(seconds=3600):
57
+ """Return the same value withing `seconds` time period"""
58
+ return round(time.time() / seconds)
59
+
60
+
61
+ def get_sha256(text: str) -> str:
62
+ return hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest()
63
+
64
+
65
+ def get_sha1(text: str) -> str:
66
+ return hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest()
edu_assistant/utils/langchain_utils.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from langchain.chat_models import AzureChatOpenAI, ChatOpenAI
4
+ from langchain.chat_models.base import BaseChatModel
5
+ from langchain.memory.chat_memory import BaseChatMemory
6
+
7
+
8
+ def load_llm() -> BaseChatModel:
9
+ if os.environ.get("AZURE_OPENAI"):
10
+ llm = AzureChatOpenAI(
11
+ openai_api_type="azure",
12
+ openai_api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
13
+ openai_api_base=os.environ.get("AZURE_OPENAI_API_BASE"),
14
+ openai_api_version="2023-05-15",
15
+ deployment_name=os.environ.get("AZURE_OPENAI_DEPLOYMENT_ID", "gpt-35-turbo"),
16
+ model="gpt-3.5-turbo",
17
+ temperature=0,
18
+ )
19
+ else:
20
+ llm = ChatOpenAI(
21
+ openai_api_key=os.environ.get("OPENAI_API_KEY"),
22
+ openai_proxy=os.environ.get("OPENAI_PROXY", ""),
23
+ model="gpt-3.5-turbo",
24
+ )
25
+
26
+ return llm
27
+
28
+
29
+ def load_gpt4_llm() -> BaseChatModel:
30
+ llm = ChatOpenAI(
31
+ openai_api_key=os.environ.get("OPENAI_API_KEY"),
32
+ openai_proxy=os.environ.get("OPENAI_PROXY", ""),
33
+ model="gpt-4",
34
+ )
35
+
36
+ return llm
37
+
38
+
39
+ def update_chat_memory(memory: BaseChatMemory, user_text: str, ai_text: str):
40
+ """update langchain memory
41
+
42
+ Args:
43
+ memory (BaseChatMemory): chat history memory
44
+ question (str): qa question str
45
+ result (str): chain answer result
46
+ """
47
+ memory.chat_memory.add_user_message(user_text)
48
+ memory.chat_memory.add_ai_message(ai_text)
examples/qa.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from edu_assistant.learning_tasks import QaTask
4
+ from edu_assistant.utils.common_utils import init_local_logging
5
+
6
+ init_local_logging()
7
+
8
+
9
+ instruction = """
10
+ Act as a c++ professional to answer student aged 5-10 questions. Answer properly and politely.
11
+ """
12
+ task = QaTask(instruction=instruction)
13
+
14
+
15
+ def qa_once():
16
+ result = task.ask("请问什么是指针?", session=False)
17
+ print(json.dumps(result, ensure_ascii=False, indent=4))
18
+
19
+
20
+ def qa_twice():
21
+ task = QaTask()
22
+ task.ask("请问如何释放一个数组?")
23
+ result = task.ask("那指针呢?")
24
+ print(json.dumps(result, ensure_ascii=False, indent=4))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ # qa_once()
29
+ qa_twice()
poetry.lock CHANGED
@@ -2517,4 +2517,4 @@ reference = "aliyun"
2517
  [metadata]
2518
  lock-version = "2.0"
2519
  python-versions = "^3.10"
2520
- content-hash = "10e258b988673e31bc74da1f6e92f9afca6bd111985746fc441d717b37c576c4"
 
2517
  [metadata]
2518
  lock-version = "2.0"
2519
  python-versions = "^3.10"
2520
+ content-hash = "bf7411574c4cfde57b9017ce88a88019ca51f05c24b086bb4477e2ff85201b51"
tests/unit_tests/learning_tasks/test_qa.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from langchain.memory import ConversationBufferMemory
5
+
6
+ from edu_assistant.learning_tasks import QaTask
7
+
8
+
9
+ class TestQaTask(unittest.TestCase):
10
+ @patch.object(QaTask, "_build_chain")
11
+ def test_init(self, mock_build_chain):
12
+ mock_build_chain.return_value = MagicMock()
13
+ QaTask()
14
+ mock_build_chain.assert_called_once()
15
+
16
+ @patch.object(QaTask, "_build_chain")
17
+ def test_ask_with_session(self, mock_build_chain):
18
+ mock_build_chain.return_value = MagicMock()
19
+ qa_task = QaTask()
20
+ qa_task._ask = MagicMock(return_value={"response": "test answer"})
21
+ result = qa_task.ask("test question")
22
+ qa_task._ask.assert_called_once()
23
+ self.assertEqual(result.get("response"), "test answer")
24
+ self.assertIsNotNone(result.get("session_id"))
25
+
26
+ @patch.object(QaTask, "_build_chain")
27
+ def test_ask_without_session(self, mock_build_chain):
28
+ mock_build_chain.return_value = MagicMock()
29
+ qa_task = QaTask()
30
+ qa_task._ask = MagicMock(return_value={"answer": "test answer"})
31
+ result = qa_task.ask("test question", session=False)
32
+ qa_task._ask.assert_called_once_with("test question")
33
+ self.assertEqual(result, {"answer": "test answer"})
34
+
35
+ @patch.object(QaTask, "_build_chain")
36
+ def test__get_session_mem(self, mock_build_chain):
37
+ mock_build_chain.return_value = MagicMock()
38
+ memory = ConversationBufferMemory()
39
+ qa_task = QaTask(session_store={1: memory})
40
+ result = qa_task._get_session_mem(1)
41
+ self.assertEqual(result, memory)
42
+
43
+ @patch.object(QaTask, "_build_chain")
44
+ def test__update_session_mem(self, mock_build_chain):
45
+ mock_build_chain.return_value = MagicMock()
46
+ memory = ConversationBufferMemory()
47
+ qa_task = QaTask()
48
+ qa_task._update_session_mem(1, memory)
49
+ self.assertEqual(qa_task._session_store[1], memory)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ unittest.main()