microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
c1c550a9028c419fdc8f4d5362d230b221982a2a

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

python/ta/test/cmpsearch.py

273lines · modecode

1# Copyright (c) Microsoft Corporation.
2# Licensed under the MIT License.
3
4import argparse
5import asyncio
6import builtins
7from dataclasses import dataclass
8import json
9
10import numpy as np
11import typechat
12
13from typeagent.aitools import utils
14from typeagent.aitools.embeddings import AsyncEmbeddingModel
15from typeagent.knowpro.answer_response_schema import AnswerResponse
16from typeagent.knowpro import answers
17from typeagent.knowpro.importing import ConversationSettings
18from typeagent.knowpro.convknowledge import create_typechat_model
19from typeagent.knowpro.interfaces import IConversation
20from typeagent.knowpro.search_query_schema import SearchQuery
21from typeagent.knowpro import searchlang
22from typeagent.podcasts.podcast import Podcast
23
24
25@dataclass
26class Context:
27 conversation: IConversation
28 query_translator: typechat.TypeChatJsonTranslator[SearchQuery]
29 answer_translator: typechat.TypeChatJsonTranslator[AnswerResponse]
30 embedding_model: AsyncEmbeddingModel
31 lang_search_options: searchlang.LanguageSearchOptions
32 answer_options: answers.AnswerContextOptions
33 interactive: bool
34
35
36def main():
37 # Parse arguments.
38
39 default_qafile = "testdata/Episode_53_Answer_results.json"
40 default_podcast_file = "testdata/Episode_53_AdrianTchaikovsky_index"
41
42 explanation = "a list of objects with 'question' and 'answer' keys"
43 parser = argparse.ArgumentParser(description="Parse Q/A data file")
44 parser.add_argument(
45 "--qafile",
46 type=str,
47 default=default_qafile,
48 help=f"Path to the data file ({explanation})",
49 )
50 parser.add_argument(
51 "--podcast",
52 type=str,
53 default=default_podcast_file,
54 help="Path to the podcast index files (excluding the '_index.json' suffix)",
55 )
56 parser.add_argument(
57 "--offset",
58 type=int,
59 default=0,
60 help="Number of initial Q/A pairs to skip",
61 )
62 parser.add_argument(
63 "--limit",
64 type=int,
65 default=0,
66 help="Number of Q/A pairs to print (0 means all)",
67 )
68 parser.add_argument(
69 "--interactive",
70 "-i",
71 action="store_true",
72 default=False,
73 help="Run in interactive mode, waiting for user input before each question",
74 )
75 args = parser.parse_args()
76
77 # Read evaluation data.
78
79 with open(args.qafile, "r") as file:
80 data = json.load(file)
81 assert isinstance(data, list), "Expected a list of Q/A pairs"
82 assert len(data) > 0, "Expected non-empty Q/A data"
83 assert all(
84 isinstance(qa_pair, dict) and "question" in qa_pair and "answer" in qa_pair
85 for qa_pair in data
86 ), "Expected each Q/A pair to be a dict with 'question' and 'answer' keys"
87
88 # Read podcast data.
89
90 utils.load_dotenv()
91 settings = ConversationSettings()
92 with utils.timelog("Loading podcast data"):
93 conversation = Podcast.read_from_file(args.podcast, settings)
94 assert conversation is not None, f"Failed to load podcast from {file!r}"
95
96 # Create translators.
97
98 model = create_typechat_model()
99 query_translator = utils.create_translator(model, SearchQuery)
100 answer_translator = utils.create_translator(model, AnswerResponse)
101
102 # Create context.
103
104 context = Context(
105 conversation,
106 query_translator,
107 answer_translator,
108 AsyncEmbeddingModel(),
109 lang_search_options=searchlang.LanguageSearchOptions(
110 compile_options=searchlang.LanguageQueryCompileOptions(
111 exact_scope=False, verb_scope=True, term_filter=None, apply_scope=True
112 ),
113 exact_match=False,
114 max_message_matches=25,
115 ),
116 answer_options=answers.AnswerContextOptions(
117 entities_top_k=50, topics_top_k=50, messages_top_k=None, chunking=None
118 ),
119 interactive=args.interactive,
120 )
121 utils.pretty_print(context.lang_search_options)
122 utils.pretty_print(context.answer_options)
123
124 # Loop over eval data, skipping duplicate questions
125 # (Those differ in 'cmd' value, which we don't support yet.)
126
127 offset = args.offset
128 limit = args.limit
129 last_q = ""
130 counter = 0
131 all_scores: list[tuple[float, int]] = [] # [(score, counter), ...]
132 for qa_pair in data:
133 question = qa_pair.get("question")
134 answer = qa_pair.get("answer")
135 if question:
136 question = question.strip()
137 if answer:
138 answer = answer.strip()
139 if not (question and answer) or question == last_q:
140 continue
141 counter += 1
142 last_q = question
143
144 # Process offset if specified.
145 if offset > 0:
146 offset -= 1
147 continue
148
149 # Wait for user input before continuing.
150 if context.interactive:
151 try:
152 input("Press Enter to continue... ")
153 except (EOFError, KeyboardInterrupt):
154 print()
155 break
156
157 # Compare the given answer with the actual answer for the question.
158 actual_answer, score = asyncio.run(compare(context, qa_pair))
159 all_scores.append((score, counter))
160 good_enough = score >= 0.97
161 sep = "-" if good_enough else "*"
162 print(sep * 25, counter, sep * 25)
163 print(f"Score: {score:.3f}; Question: {question}", flush=True)
164 if context.interactive or not good_enough:
165 cmd = qa_pair.get("cmd")
166 if cmd and cmd != f'@kpAnswer --query "{question}"':
167 print(f"Command: {cmd}")
168 if qa_pair.get("hasNoAnswer"):
169 answer = f"Failure: {answer}"
170 print(f"Expected answer:\n{answer}")
171 print("-" * 20)
172 print(f"Actual answer:\n{actual_answer}", flush=True)
173
174 # Process limit if specified.
175 if limit > 0:
176 limit -= 1
177 if limit == 0:
178 break
179
180 print("=" * 50)
181 all_scores.sort(reverse=True)
182 good_scores = [(score, counter) for score, counter in all_scores if score >= 0.97]
183 bad_scores = [(score, counter) for score, counter in all_scores if score < 0.97]
184 for label, pairs in [("Good", good_scores), ("Bad", bad_scores)]:
185 print(f"{label} scores ({len(pairs)}):")
186 for i in range(0, len(pairs), 10):
187 print(
188 ", ".join(
189 f"{score:.3f}({counter})" for score, counter in pairs[i : i + 10]
190 )
191 )
192
193
194async def compare(
195 context: Context, qa_pair: dict[str, str | None]
196) -> tuple[str | None, float]:
197 the_answer: str | None = None
198 score = 0.0
199
200 question = qa_pair.get("question")
201 answer = qa_pair.get("answer")
202 failed = qa_pair.get("hasNoAnswer")
203 cmd = qa_pair.get("cmd")
204 if not (question and answer):
205 return None, score
206
207 if not context.interactive:
208 print = lambda *args, **kwds: None # Disable printing in non-interactive mode
209 else:
210 print = builtins.print
211
212 print()
213 print("=" * 40)
214 if cmd:
215 print(f"Command: {cmd}")
216 print(f"Question: {question}")
217 print(f"Answer: {answer}")
218 print("-" * 40)
219
220 result = await searchlang.search_conversation_with_language(
221 context.conversation,
222 context.query_translator,
223 question,
224 context.lang_search_options,
225 )
226 print("-" * 40)
227 if not isinstance(result, typechat.Success):
228 print("Error:", result.message)
229 else:
230 all_answers, combined_answer = await answers.generate_answers(
231 context.answer_translator,
232 result.value,
233 context.conversation,
234 question,
235 options=context.answer_options,
236 )
237 print("-" * 40)
238 if combined_answer.type == "NoAnswer":
239 if failed:
240 score = 1.0
241 the_answer = f"Failure: {combined_answer.whyNoAnswer}"
242 print(the_answer)
243 print("All answers:")
244 if context.interactive:
245 utils.pretty_print(all_answers)
246 else:
247 assert combined_answer.answer is not None, "Expected an answer"
248 the_answer = combined_answer.answer
249 if failed:
250 score = 0.0
251 else:
252 score = await equality_score(context, answer, the_answer)
253 print(the_answer)
254 print("Correctness score:", score)
255 print("=" * 40)
256
257 return the_answer, score
258
259
260async def equality_score(context: Context, a: str, b: str) -> float:
261 a = a.strip()
262 b = b.strip()
263 if a == b:
264 return 1.0
265 if a.lower() == b.lower():
266 return 0.999
267 embeddings = await context.embedding_model.get_embeddings([a, b])
268 assert embeddings.shape[0] == 2, "Expected two embeddings"
269 return np.dot(embeddings[0], embeddings[1])
270
271
272if __name__ == "__main__":
273 main()
274