will123654 commited on
Commit
bc300ce
·
1 Parent(s): b6bfe53
Files changed (12) hide show
  1. .gitignore +4 -0
  2. .python-version +1 -0
  3. README.md +8 -6
  4. app.py +80 -0
  5. assets/chatbot_icon.png +0 -0
  6. config.py +66 -0
  7. helpers.py +164 -0
  8. knowledge.pkl +3 -0
  9. knowledge.py +304 -0
  10. pyproject.toml +15 -0
  11. requirements.txt +16 -0
  12. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__
2
+ cvdata/
3
+ .vscode/
4
+ .venv/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
README.md CHANGED
@@ -1,13 +1,15 @@
1
  ---
2
- title: Cvchatbot
3
- emoji: 🏃
4
- colorFrom: red
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.5.1
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: cvchatbot
3
+ emoji: 💬
4
+ colorFrom: yellow
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.3.0
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
+ hf_oauth: true
12
+ hf_oauth_scopes:
13
+ - inference-api
14
  ---
15
 
 
app.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import logging
3
+ import gradio as gr
4
+ from huggingface_hub import login
5
+ import spaces
6
+
7
+
8
+ parser = argparse.ArgumentParser("cvchatbot")
9
+ parser.add_argument("--debug", help="Enable debug logging", action='store_true')
10
+ parser.add_argument("--generate", dest="generate_cache", help="Generate new cache", action='store_true')
11
+ parser.add_argument("--download-cv-data", dest="download_cv_data", help="Download CV data from Cloud Storage", action='store_true')
12
+ args = parser.parse_args()
13
+ logger = logging.getLogger(__name__)
14
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
15
+
16
+
17
+ from config import CV_DATA_DIRNAME, EMBEDDING_MODEL_ID, HF_TOKEN, LLM_ID, \
18
+ WELCOME_MESSAGE_TEMPLATE, NO_MATCH_RESPONSE, RESET_QUERY, \
19
+ CV_DATA_FILE_EDUCATION, CV_DATA_FILE_JOBS, CV_DATA_FILE_PAPERS, CV_DATA_FILE_SKILLS, \
20
+ EDUCATION_DESCRIPTION, JOBS_DESCRIPTION, PAPERS_DESCRIPTION, SKILLS_DESCRIPTION
21
+ from helpers import get_education_documents, get_job_documents, get_paper_documents, get_skill_documents
22
+ from knowledge import ChatContext, DomainKnowledgeConfig, KnowledgeBase, KnowledgeConfig
23
+
24
+
25
+ logger.info("Logging in to Hugging Face Hub...")
26
+ login(token=HF_TOKEN)
27
+
28
+ logger.info("Initializing CV Knowledge Base...")
29
+ cv_knowledge_base = KnowledgeBase(
30
+ KnowledgeConfig(
31
+ WELCOME_MESSAGE_TEMPLATE,
32
+ NO_MATCH_RESPONSE,
33
+ RESET_QUERY,
34
+ [
35
+ DomainKnowledgeConfig(JOBS_DESCRIPTION, CV_DATA_DIRNAME + CV_DATA_FILE_JOBS, get_job_documents),
36
+ DomainKnowledgeConfig(SKILLS_DESCRIPTION, CV_DATA_DIRNAME + CV_DATA_FILE_SKILLS, get_skill_documents),
37
+ DomainKnowledgeConfig(EDUCATION_DESCRIPTION, CV_DATA_DIRNAME + CV_DATA_FILE_EDUCATION, get_education_documents),
38
+ DomainKnowledgeConfig(PAPERS_DESCRIPTION, CV_DATA_DIRNAME + CV_DATA_FILE_PAPERS, get_paper_documents),
39
+ ],
40
+ ),
41
+ args.generate_cache,
42
+ args.download_cv_data,
43
+ )
44
+
45
+
46
+ @spaces.GPU
47
+ def respond(
48
+ message: str,
49
+ history: list[gr.MessageDict],
50
+ chat_context: ChatContext,
51
+ ):
52
+ yield from cv_knowledge_base.answer_query(message, history, chat_context)
53
+
54
+
55
+ with gr.Blocks() as demo:
56
+ # login button
57
+ with gr.Sidebar():
58
+ gr.LoginButton()
59
+
60
+ # chat context
61
+ context_state = gr.State(ChatContext())
62
+
63
+ # chat interface
64
+ gr.ChatInterface(
65
+ respond,
66
+ chatbot=gr.Chatbot(
67
+ label=f"{LLM_ID} + {EMBEDDING_MODEL_ID}",
68
+ value=[gr.MessageDict(role="assistant", content=cv_knowledge_base.knowledge.welcome_message)],
69
+ avatar_images=(None, "./assets/chatbot_icon.png"),
70
+ ),
71
+ textbox=gr.Textbox(
72
+ submit_btn=True,
73
+ ),
74
+ additional_inputs=[context_state], # Pass the updated state as an input
75
+ additional_outputs=[context_state], # Receive the updated state as an output
76
+ )
77
+
78
+
79
+ if __name__ == "__main__":
80
+ demo.launch()
assets/chatbot_icon.png ADDED
config.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from string import Template
3
+
4
+
5
+ HF_TOKEN = os.getenv('HF_TOKEN')
6
+ # LLM_ID = "google/gemma-3-1b-it"
7
+ LLM_ID = "google/gemma-3-4b-it"
8
+ EMBEDDING_MODEL_ID = "google/embeddinggemma-300M"
9
+ PERSON_NAME = "William"
10
+ PERSON_PRONOUN = "he"
11
+
12
+ # cv document data files
13
+ CV_DATA_GCS_BUCKET = "wlee-jsons"
14
+ CV_DATA_DIRNAME = "cvdata/"
15
+ CV_DATA_FILE_EDUCATION = "education.json"
16
+ CV_DATA_FILE_JOBS = "jobs.json"
17
+ CV_DATA_FILE_PAPERS = "papers.json"
18
+ CV_DATA_FILE_SKILLS = "skills.json"
19
+ RESET_QUERY = "restart chat"
20
+ WELCOME_MESSAGE_TEMPLATE = Template(f"Hi! I'm William's Career Assistant. I can answer questions you might have regarding William's resume. "
21
+ "To start, tell me which one of the following domains you would like to focus on:\n"
22
+ "$domains\n"
23
+ f"At any point in chat, say \'{RESET_QUERY}\' to reset chat and return to current state.")
24
+ NO_MATCH_RESPONSE = "I don't have that specific information right now."
25
+ EDUCATION_DESCRIPTION = "Academic degrees and relevant coursework"
26
+ JOBS_DESCRIPTION = "Work experience"
27
+ PAPERS_DESCRIPTION = "Papers and publications"
28
+ SKILLS_DESCRIPTION = "Technical skills, such as programming language, framework, and tools"
29
+
30
+ # cache files
31
+ KNOWLEDGE_CACHE_FILE = "knowledge.pkl"
32
+
33
+ # embedding parameters
34
+ DOMAIN_SELECTION_THRESHOLD = 0.5
35
+ DOCUMENT_SELECTION_THRESHOLD = 0.2
36
+ DOCUMENT_FOLLOWUP_THRESHOLD = 0.5
37
+
38
+ # generation parameters
39
+ RESPONSE_MAX_NEW_TOKENS = 512
40
+ RESPONSE_SAMPLING_TOP_P = 0.95
41
+ RESPONSE_SAMPLING_TEMPERATURE = 0.7
42
+
43
+ # helper configs
44
+ COURSE_DEPARTMENT_NAMES = {
45
+ "MAT": "Mathematics",
46
+ "ECO": "Economics",
47
+ "ORF": "Operations Research and Financial Engineering",
48
+ "COS": "Computer Science",
49
+ "PHI": "Philosophy",
50
+ "LIN": "Linguistics",
51
+ "AST": "Astronomy",
52
+ "CEE": "Civil and Environmental Engineering",
53
+ "PHY": "Physics",
54
+ "MUS": "Music",
55
+ }
56
+ SKILL_COMPETENCY_LEVELS = {
57
+ 5: "Expert",
58
+ 4: "Proficient",
59
+ 3: "Competent",
60
+ 2: "Advanced Beginner",
61
+ 1: "Novice",
62
+ }
63
+ JOB_TYPES = [
64
+ "Software Engineer",
65
+ "Research Intern",
66
+ ]
helpers.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timedelta, datetime
2
+ from collections import defaultdict
3
+ import json
4
+ from typing import Tuple
5
+ from google.cloud.storage import Client, transfer_manager
6
+
7
+ from config import COURSE_DEPARTMENT_NAMES, JOB_TYPES, PERSON_NAME, PERSON_PRONOUN, RESET_QUERY, SKILL_COMPETENCY_LEVELS, \
8
+ CV_DATA_DIRNAME, CV_DATA_GCS_BUCKET
9
+
10
+
11
+ def download_cv_data() -> None:
12
+ # download cv data from google cloud storage
13
+ storage_client = Client()
14
+ bucket = storage_client.bucket(CV_DATA_GCS_BUCKET)
15
+ cv_document_blobs = [blob.name for blob in bucket.list_blobs(prefix=CV_DATA_DIRNAME)]
16
+ results = transfer_manager.download_many_to_path(bucket, cv_document_blobs)
17
+ if not all(result is None for result in results):
18
+ exception_message = ""
19
+ for i, download_result in enumerate(results):
20
+ if download_result is not None:
21
+ exception_message += f"❗ Exception occurred while downloading {cv_document_blobs[i]} from {CV_DATA_GCS_BUCKET}: {download_result}; "
22
+
23
+ raise RuntimeError(exception_message)
24
+
25
+ def get_job_type(position: str) -> str:
26
+ position_lower = position.lower()
27
+ for job_type in JOB_TYPES:
28
+ if job_type.lower() in position_lower:
29
+ return job_type
30
+ raise ValueError(f"Position '{position}' does not match any known job types.")
31
+
32
+ def join_grammatically(words: list[str]) -> str:
33
+ if len(words) == 0:
34
+ raise ValueError("words must contain at least one word.")
35
+ elif len(words) == 1:
36
+ return words[0]
37
+ return ", ".join(words[:-2] + [" and ".join(words[-2:])])
38
+
39
+ def td_to_yoe_str(td: timedelta) -> str:
40
+ secs_in_year = 365.2425 * 24 * 3600
41
+ td_secs = td.total_seconds()
42
+ if td_secs < secs_in_year:
43
+ secs_in_month = 30.436875 * 24 * 3600
44
+ months = round(td_secs / secs_in_month)
45
+ return f"{months} {'month' if months <= 1 else 'months'}"
46
+ else:
47
+ return f"{td_secs / secs_in_year:.1f} years"
48
+
49
+ def get_job_documents(jobs_json: dict) -> Tuple[str, list[str]]:
50
+ jobs_docs = []
51
+ positions_in_order = []
52
+ yoe_by_job_type = defaultdict(timedelta)
53
+ for job_json in jobs_json["jobs"].values():
54
+ company_word = f"[{job_json["company"]["name"]}]({job_json["company"]["link"]}) in {job_json["company"]["location"]}"
55
+ for position in job_json["positions"]:
56
+ start_date = datetime.strptime(position["startDate"], "%Y-%m-%d")
57
+ end_date = datetime.strptime(position["endDate"], "%Y-%m-%d")
58
+ yoe_by_job_type[get_job_type(position["name"])] += end_date - start_date
59
+
60
+ job_doc = f"{PERSON_NAME} worked as {position["name"]} at {company_word} from {start_date.strftime('%B %Y')} to {end_date.strftime('%B %Y')}."
61
+ job_doc += f" Following are highlights of this position: {'; '.join(position['highlights'])}."
62
+ jobs_docs.append(job_doc)
63
+
64
+ positions_in_order.append(f"➢ {position["name"]} at {company_word} ({start_date.strftime('%B %Y')} - {end_date.strftime('%B %Y')})")
65
+
66
+ yoe_by_job_type_strs = [f"{td_to_yoe_str(yoe)} of experience as {job_type}" for job_type, yoe in yoe_by_job_type.items()]
67
+ intro_message = f"{PERSON_NAME} has {join_grammatically(yoe_by_job_type_strs)}. Following are positions {PERSON_PRONOUN} has worked as:\n"
68
+ intro_message += "\n".join(positions_in_order)
69
+ intro_message += f"\nYou can ask me about more details on any of these positions. As a reminder, you can say \'{RESET_QUERY}\' to reset chat."
70
+
71
+ return intro_message, jobs_docs
72
+
73
+ def get_education_documents(education_json: dict) -> Tuple[str, list[str]]:
74
+ education_docs = []
75
+
76
+ university_name = education_json["school"]["name"]
77
+ university_end_date = datetime.strptime(education_json["endDate"], "%Y-%m-%d").strftime("%B %Y")
78
+ degree_full_str = f"{education_json["degree"]["name"]}"
79
+ minors = [minor["name"] for minor in education_json["minors"]]
80
+ if len(minors) == 1:
81
+ degree_full_str += f" with minor in {minors[0]}"
82
+ elif len(minors) > 1:
83
+ degree_full_str += f" with minors in {", ".join(minors)}"
84
+
85
+ dept_courses = defaultdict(list[str])
86
+ for course_json in education_json["courses"]:
87
+ course_number = course_json["number"]
88
+ course_dept = course_number.split(" ")[0]
89
+ dept_courses[course_dept].append(f"➢ [{course_json["number"]}]({course_json["link"]}): {course_json["title"]}")
90
+
91
+ sorted_depts = sorted(dept_courses, key=lambda k: len(dept_courses[k]), reverse=True)
92
+ for dept in sorted_depts:
93
+ education_docs.append(f"In department of {COURSE_DEPARTMENT_NAMES[dept]}, {PERSON_NAME} took \n{"\n".join(dept_courses[dept])}.")
94
+ sorted_dept_names = "\n".join([f"➢ {COURSE_DEPARTMENT_NAMES[dept]} ({dept})" for dept in sorted_depts])
95
+
96
+ intro_message = (f"{PERSON_NAME} graduated from {university_name} in {university_end_date} with {degree_full_str}. "
97
+ f"{PERSON_PRONOUN.title()} took {len(education_json["courses"])} courses across following departments:\n{sorted_dept_names}."
98
+ f"\nYou can ask me about detailed list of courses {PERSON_NAME} took in each department. As a reminder, you can say \'{RESET_QUERY}\' to reset chat.")
99
+
100
+ return intro_message, education_docs
101
+
102
+ def get_paper_documents(papers_json: dict) -> Tuple[str, list[str]]:
103
+ intro_messages = []
104
+ paper_docs = []
105
+ for i, paper_json in enumerate(papers_json["papers"]):
106
+ paper_date = datetime.strptime(paper_json["date"], "%Y-%m")
107
+ intro_messages.append(f"{i + 1}: {paper_json["title"]} ({paper_date.strftime("%Y")})")
108
+ paper_doc = f"{i + 1}: Title of this paper is {paper_json["title"]}."
109
+
110
+ location_str = f" in {paper_json["location"]}" if "location" in paper_json else ""
111
+ paper_doc += f" It was published on {paper_date.strftime("%B %Y")}{location_str}."
112
+
113
+ if "conference" in paper_json:
114
+ paper_doc += f" It was published in {paper_json["conference"]}."
115
+
116
+ paper_doc += f" Link to paper is {paper_json["paperUrl"]}."
117
+
118
+ paper_doc += f" Following is description of this paper. {paper_json["desc"]}"
119
+
120
+ paper_docs.append(paper_doc)
121
+
122
+ intro_message = (f"{PERSON_NAME} wrote {len(intro_messages)} papers. Here are titles of these papers:\n{"\n".join(intro_messages)}"
123
+ f"\nYou can ask me about more details on any of these papers. As a reminder, you can say \'{RESET_QUERY}\' to reset chat.")
124
+
125
+ return intro_message, paper_docs
126
+
127
+ def get_skill_documents(skills_json: dict) -> Tuple[str, list[str]]:
128
+ skill_competency = defaultdict(int)
129
+ skills_by_category = defaultdict(list)
130
+ for skill_json in skills_json["skills"]:
131
+ skill_competency[skill_json["title"]] = skill_json["competency"]
132
+ for skill_category in skill_json["category"]:
133
+ skills_by_category[skill_category].append(skill_json["title"])
134
+
135
+ skill_docs = []
136
+ for category, skills in skills_by_category.items():
137
+ skills.sort(key=lambda x: skill_competency[x], reverse=True)
138
+ skill_docs.append(f"Technical skills related to {category} that {PERSON_NAME} has are: {", ".join(skills)}.")
139
+
140
+ sorted_skill_categories = list(skills_by_category.keys())
141
+ sorted_skill_categories.sort()
142
+ intro_message = (f"{PERSON_NAME} has various technical skills across following categories:\n"
143
+ f"{'\n'.join([f"➢ {category}" for category in sorted_skill_categories])}"
144
+ f"\nYou can ask me which technical skills {PERSON_NAME} has in one of these areas. As a reminder, you can say \'{RESET_QUERY}\' to reset chat.")
145
+
146
+ return intro_message, skill_docs
147
+
148
+ def print_docs(name, file_name, parse_func):
149
+ with open(f"cvdata/{file_name}") as f:
150
+ intro_message, docs = parse_func(json.load(f))
151
+
152
+ print(f'\n>>>>>>>>>> {name} <<<<<<<<<<')
153
+ print('========== Intro')
154
+ print(intro_message)
155
+ print('\n========== Docs')
156
+ for doc in docs:
157
+ print(doc + '\n')
158
+
159
+
160
+ if __name__ == "__main__":
161
+ print_docs("Jobs", "jobs.json", get_job_documents)
162
+ print_docs("Education", "education.json", get_education_documents)
163
+ print_docs("Papers", "papers.json", get_paper_documents)
164
+ print_docs("Skills", "skills.json", get_skill_documents)
knowledge.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85a4b6d3b961b41367c6f09f73a522513ba8bae2f9e3c3c08eae6c40bd97e459
3
+ size 156587
knowledge.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import pickle
5
+ from collections.abc import Callable, Generator
6
+ from string import Template
7
+ from threading import Thread
8
+ from typing import Any, Tuple
9
+ from gradio import MessageDict
10
+ from accelerate import Accelerator
11
+ from sentence_transformers import SentenceTransformer
12
+ from transformers import TextIteratorStreamer, pipeline
13
+ import torch
14
+
15
+ from config import EMBEDDING_MODEL_ID, LLM_ID, KNOWLEDGE_CACHE_FILE, \
16
+ DOCUMENT_FOLLOWUP_THRESHOLD, DOCUMENT_SELECTION_THRESHOLD, DOMAIN_SELECTION_THRESHOLD, \
17
+ RESPONSE_MAX_NEW_TOKENS, RESPONSE_SAMPLING_TEMPERATURE, RESPONSE_SAMPLING_TOP_P
18
+ from helpers import download_cv_data
19
+
20
+ logger = logging.getLogger(__name__)
21
+ accelerator = Accelerator()
22
+ device = accelerator.device
23
+
24
+ class DomainKnowledge:
25
+ def __init__(
26
+ self,
27
+ desc: str,
28
+ intro_message: str,
29
+ documents: list[str],
30
+ document_embeddings: torch.Tensor,
31
+ ):
32
+ self.description = desc
33
+ self.intro_message = intro_message
34
+ self.documents = documents
35
+ self.document_embeddings = document_embeddings
36
+
37
+
38
+ class DomainKnowledgeConfig:
39
+ def __init__(self, description, file_name: str, parse_file_fn: Callable[[Any], Tuple[str, list[str]]]):
40
+ self.desc = description
41
+ self.file_name = file_name
42
+ self.parse_file = parse_file_fn
43
+
44
+ def initialize(self, embedding_model: SentenceTransformer) -> DomainKnowledge:
45
+ with open(self.file_name) as f:
46
+ document_file = json.load(f)
47
+
48
+ intro_message, documents = self.parse_file(document_file)
49
+ document_embeddings: torch.Tensor = embedding_model.encode_document(
50
+ documents,
51
+ convert_to_tensor=True,
52
+ device=device,
53
+ ) # type: ignore
54
+ return DomainKnowledge(self.desc, intro_message, documents, document_embeddings)
55
+
56
+
57
+ class Knowledge:
58
+ def __init__(
59
+ self,
60
+ welcome_message: str,
61
+ no_match_response: str,
62
+ reset_query: str,
63
+ domain_knowledges: list[DomainKnowledge],
64
+ domain_knowledge_description_embeddings: torch.Tensor,
65
+ ):
66
+ self.welcome_message = welcome_message
67
+ self.no_match_response = no_match_response
68
+ self.reset_query = reset_query
69
+ self.domain_knowledges = domain_knowledges
70
+ self.dk_desc_embeddings = domain_knowledge_description_embeddings
71
+
72
+
73
+ class KnowledgeConfig:
74
+ def __init__(
75
+ self,
76
+ welcome_message_template: Template,
77
+ no_match_response: str,
78
+ reset_query: str,
79
+ domain_knowledge_configs: list[DomainKnowledgeConfig],
80
+ ):
81
+ domain_descriptions = "\n".join([f"➢ {dk_cfg.desc}" for dk_cfg in domain_knowledge_configs])
82
+ self.welcome_message = welcome_message_template.substitute(domains=domain_descriptions)
83
+ self.no_match_response = no_match_response
84
+ self.reset_query = reset_query
85
+ self.domain_knowledge_configs = domain_knowledge_configs
86
+
87
+ def initialize(
88
+ self,
89
+ embedding_model: SentenceTransformer,
90
+ generate_cache: bool,
91
+ download_data: bool,
92
+ ) -> Knowledge:
93
+ if generate_cache or not os.path.exists(KNOWLEDGE_CACHE_FILE):
94
+ logger.info("⚠️ Initializing cv knowledge...")
95
+ if download_data:
96
+ logger.info("⚠️ Downloading cv data...")
97
+ download_cv_data()
98
+
99
+ domain_knowledges = []
100
+ domain_knowledge_descriptions = []
101
+ for dk_cfg in self.domain_knowledge_configs:
102
+ domain_knowledge = dk_cfg.initialize(embedding_model)
103
+ domain_knowledges.append(domain_knowledge)
104
+ domain_knowledge_descriptions.append(domain_knowledge.description)
105
+
106
+ domain_knowledge_description_embeddings: torch.Tensor = embedding_model.encode_document(
107
+ domain_knowledge_descriptions,
108
+ convert_to_tensor=True,
109
+ device=device,
110
+ ) # type: ignore
111
+
112
+ knowledge = Knowledge(
113
+ self.welcome_message,
114
+ self.no_match_response,
115
+ self.reset_query,
116
+ domain_knowledges,
117
+ domain_knowledge_description_embeddings,
118
+ )
119
+
120
+ with open(KNOWLEDGE_CACHE_FILE, 'wb') as f:
121
+ pickle.dump(knowledge, f)
122
+
123
+ logger.info(f"✅ Cached cv knowledge to '{KNOWLEDGE_CACHE_FILE}'.")
124
+ else:
125
+ logger.info(f"✅ Found cache for cv knowledge. Loading data from '{KNOWLEDGE_CACHE_FILE}'...")
126
+ with open(KNOWLEDGE_CACHE_FILE, 'rb') as f:
127
+ knowledge = pickle.load(f)
128
+
129
+ return knowledge
130
+
131
+
132
+ class ChatContext:
133
+ """
134
+ Holds the conversational state that uses knowledge base and LLM to answer questions.
135
+ Context tree:
136
+ 1. Learn about jobs
137
+ a. position a, company x
138
+ b. position b, company x
139
+ ...
140
+ 2. Learn about tech stack and skills
141
+ a. languages
142
+ b. domain of expertise
143
+ c. tech stacks
144
+ 3. Learn about papers
145
+ a. paper 1
146
+ b. paper 2
147
+ ...
148
+ 4. Learn about education
149
+ """
150
+ def __init__(self):
151
+ self.domain_index = -1
152
+ self.document_index = -1
153
+
154
+
155
+ class KnowledgeBase:
156
+ def __init__(
157
+ self,
158
+ knowledge_config: KnowledgeConfig,
159
+ generate_cache: bool,
160
+ download_data: bool,
161
+ ):
162
+ self.embedding_model = SentenceTransformer(EMBEDDING_MODEL_ID, device='cuda')
163
+ self.llm_pipeline = pipeline(task="text-generation", model=LLM_ID, device='cuda')
164
+ self.knowledge = knowledge_config.initialize(self.embedding_model, generate_cache, download_data)
165
+
166
+ def semantic_search_domains(self, query: str) -> int:
167
+ """symmetric semantic search on domain descriptions"""
168
+ query_embedding: torch.Tensor = self.embedding_model.encode_query(query, convert_to_tensor=True) # type: ignore
169
+ similarity_scores = self.embedding_model.similarity(query_embedding, self.knowledge.dk_desc_embeddings)
170
+ if similarity_scores.numel() == 0:
171
+ logger.debug("Calculating similarities returned an empty tensor.")
172
+ return -1
173
+
174
+ best_index = similarity_scores.argmax()
175
+ best_similarity = similarity_scores[0, best_index]
176
+ if best_similarity >= DOMAIN_SELECTION_THRESHOLD:
177
+ logger.debug(f"domain description index {best_index} with similarity {best_similarity} exceeds threshold {DOMAIN_SELECTION_THRESHOLD}.")
178
+ return int(best_index)
179
+
180
+ logger.debug(f"best domain description at {best_index} with similarity {best_similarity} is below threshold {DOMAIN_SELECTION_THRESHOLD}.")
181
+ return -1
182
+
183
+ def semantic_search_documents(self, query: str, chat_context: ChatContext) -> int:
184
+ """asymmetric semantic search on documents"""
185
+ query_embedding: torch.Tensor = self.embedding_model.encode_query(query, convert_to_tensor=True) # type: ignore
186
+ similarity_threshold = DOCUMENT_SELECTION_THRESHOLD if chat_context.document_index == -1 else DOCUMENT_FOLLOWUP_THRESHOLD
187
+ document_embeddings = self.knowledge.domain_knowledges[chat_context.domain_index].document_embeddings
188
+
189
+ similarity_scores = self.embedding_model.similarity(query_embedding, document_embeddings)
190
+ if similarity_scores.numel() == 0:
191
+ logger.debug("Calculating similarities returned an empty tensor.")
192
+ return -1
193
+
194
+ best_index = similarity_scores.argmax()
195
+ best_similarity = similarity_scores[0, best_index]
196
+ if best_similarity >= similarity_threshold:
197
+ logger.debug(f"in domain {chat_context.domain_index}, document at {best_index} with similarity {best_similarity} exceeds threshold {similarity_threshold}.")
198
+ return int(best_index)
199
+
200
+ logger.debug(f"in domain {chat_context.domain_index}, document at {best_index} with best similarity {best_similarity} is below threshold {similarity_threshold}.")
201
+ return -1
202
+
203
+ def get_system_prompt(self, context_str: str) -> str:
204
+ return f"""
205
+ \rAnswer the following QUESTION based only on the CONTEXT provided. If the answer cannot be found in the CONTEXT, write \"{self.knowledge.no_match_response}\"
206
+ \r---
207
+ \rCONTEXT:
208
+ \r{context_str}"""
209
+
210
+ def get_llm_prompt(self, chat_context: ChatContext, user_input: str, history: list[MessageDict]) -> list[MessageDict]:
211
+ """Returns the prompt to ask LLM to generate response."""
212
+ document_content = self.knowledge.domain_knowledges[chat_context.domain_index].documents[chat_context.document_index]
213
+
214
+ llm_prompt = list()
215
+ llm_prompt.append(MessageDict(role="system", content=self.get_system_prompt(document_content)))
216
+ llm_prompt.extend(history[1:]) # skip welcome message
217
+ llm_prompt.append(MessageDict(role="user", content=f"QUESTION:\n{user_input}"))
218
+
219
+ logger.debug("generated llm prompt:")
220
+ logger.debug(f"system: {llm_prompt[0]["content"]}")
221
+ logger.debug(f"user: {llm_prompt[-1]["content"]}")
222
+
223
+ return llm_prompt
224
+
225
+ def intro_response(self, chat_context: ChatContext) -> Generator[Tuple[str, ChatContext]]:
226
+ yield self.knowledge.welcome_message, chat_context
227
+
228
+ def no_match_response(self, chat_context: ChatContext) -> Generator[Tuple[str, ChatContext]]:
229
+ yield self.knowledge.no_match_response, chat_context
230
+
231
+ def answer_query(
232
+ self,
233
+ user_message: str,
234
+ history: list[MessageDict],
235
+ chat_context: ChatContext,
236
+ ) -> Generator[Tuple[str, ChatContext]]:
237
+ """Generates a response based on the user message and chat context."""
238
+ logger.debug(f"user input: {user_message}")
239
+
240
+ if user_message == self.knowledge.reset_query:
241
+ # reset chat
242
+ logger.debug("resetting chat")
243
+ chat_context.domain_index = -1
244
+ chat_context.document_index = -1
245
+ yield from self.intro_response(chat_context)
246
+ return
247
+ elif len(history) == 0:
248
+ # new convo
249
+ logger.debug("starting a new chat")
250
+ yield from self.intro_response(chat_context)
251
+ return
252
+ elif chat_context.domain_index == -1:
253
+ # domain selection chat
254
+ domain_index = self.semantic_search_domains(user_message)
255
+ if domain_index >= 0:
256
+ chat_context.domain_index = domain_index
257
+ yield self.knowledge.domain_knowledges[domain_index].intro_message, chat_context
258
+ return
259
+ else:
260
+ logger.debug("failed to find matching domain")
261
+ yield from self.no_match_response(chat_context)
262
+ return
263
+ else:
264
+ # domain chat
265
+ document_index = self.semantic_search_documents(user_message, chat_context)
266
+ if document_index >= 0:
267
+ # found content similar to user's input. use that content.
268
+ chat_context.document_index = document_index
269
+ logger.debug(f"using document at index {chat_context.document_index} in domain {chat_context.domain_index} that matches user's input.")
270
+ elif chat_context.document_index >= 0:
271
+ # no specific content matching user's input, so use previous document in chat_context.
272
+ logger.debug(f"no content matches user's input. using previous document at index {chat_context.document_index} in domain {chat_context.domain_index}.")
273
+ else:
274
+ # no content found and no previous content. yield no match.
275
+ logger.debug("no content found and no previous content.")
276
+ yield from self.no_match_response(chat_context)
277
+ return
278
+
279
+ # Generate response using LLM
280
+ llm_prompt = self.get_llm_prompt(chat_context, user_message, history)
281
+
282
+ llm_response_streamer = TextIteratorStreamer(self.llm_pipeline.tokenizer, skip_prompt=True) # type: ignore
283
+ generation_args = dict(
284
+ text_inputs=llm_prompt,
285
+ streamer=llm_response_streamer,
286
+ skip_special_tokens=True,
287
+ max_new_tokens=RESPONSE_MAX_NEW_TOKENS,
288
+ do_sample=True,
289
+ top_p=RESPONSE_SAMPLING_TOP_P,
290
+ temperature=RESPONSE_SAMPLING_TEMPERATURE,
291
+ )
292
+
293
+ thread = Thread(
294
+ target=self.llm_pipeline,
295
+ kwargs=generation_args
296
+ )
297
+ thread.start()
298
+
299
+ response = ""
300
+ for new_text in llm_response_streamer:
301
+ response += new_text
302
+ yield response, chat_context
303
+
304
+ logger.debug(f"[assistant] {response}")
pyproject.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "cvchatbot_wjlee"
3
+ version = "0.1.0"
4
+ description = "Chatbot app that answers questions about CV"
5
+ readme = "README.md"
6
+ requires-python = "==3.12.*"
7
+ dependencies = [
8
+ "accelerate>=1.12.0",
9
+ "google-cloud-storage>=3.8.0",
10
+ "gradio[oauth]>=6.3.0",
11
+ "hf-xet>=1.2.0",
12
+ "sentence-transformers>=5.2.0",
13
+ "spaces>=0.46.0",
14
+ "transformers>=4.57.5",
15
+ ]
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv pip compile pyproject.toml -o requirements.txt --no-deps --no-strip-extras
3
+ accelerate==1.12.0
4
+ # via cvchatbot-wjlee (pyproject.toml)
5
+ google-cloud-storage==3.8.0
6
+ # via cvchatbot-wjlee (pyproject.toml)
7
+ gradio[oauth]==6.3.0
8
+ # via cvchatbot-wjlee (pyproject.toml)
9
+ hf-xet==1.2.0
10
+ # via cvchatbot-wjlee (pyproject.toml)
11
+ sentence-transformers==5.2.0
12
+ # via cvchatbot-wjlee (pyproject.toml)
13
+ spaces==0.46.0
14
+ # via cvchatbot-wjlee (pyproject.toml)
15
+ transformers==4.57.5
16
+ # via cvchatbot-wjlee (pyproject.toml)
uv.lock ADDED
The diff for this file is too large to render. See raw diff