microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
c0e214613f856c98a3f79b484bb93a74a1cac504

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

853lines · modecode

1# Copyright (c) Microsoft Corporation.
2# Licensed under the MIT License.
3
4__version__ = "0.2"
5
6### Imports ###
7
8import argparse
9import asyncio
10from collections.abc import Mapping
11from dataclasses import dataclass
12import difflib
13import json
14import re
15import shutil
16import sys
17import typing
18
19from colorama import init as colorama_init, Fore
20import numpy as np
21
22try:
23 import readline
24except ImportError:
25 readline = None
26import typechat
27
28from typeagent.aitools import embeddings
29from typeagent.aitools import utils
30
31from typeagent.knowpro.interfaces import (
32 IConversation,
33 IMessage,
34 ITermToSemanticRefIndex,
35 ScoredMessageOrdinal,
36 ScoredSemanticRefOrdinal,
37 SemanticRef,
38 Topic,
39)
40from typeagent.knowpro import answers, answer_response_schema
41from typeagent.knowpro import convknowledge
42from typeagent.knowpro import importing
43from typeagent.knowpro import kplib
44from typeagent.knowpro import query
45from typeagent.knowpro import search, search_query_schema, searchlang
46from typeagent.knowpro import serialization
47
48from typeagent.podcasts import podcast
49
50
51### Logfire setup ###
52
53
54def setup_logfire():
55 import logfire
56
57 def scrubbing_callback(m: logfire.ScrubMatch):
58 # if m.path == ('attributes', 'http.request.header.authorization'):
59 # return m.value
60
61 # if m.path == ('attributes', 'http.request.header.api-key'):
62 # return m.value
63
64 if (
65 m.path == ("attributes", "http.request.body.text", "messages", 0, "content")
66 and m.pattern_match.group(0) == "secret"
67 ):
68 return m.value
69
70 # if m.path == ('attributes', 'http.response.header.azureml-model-session'):
71 # return m.value
72
73 logfire.configure(scrubbing=logfire.ScrubbingOptions(callback=scrubbing_callback))
74 logfire.instrument_pydantic_ai()
75 logfire.instrument_httpx(capture_all=True)
76
77
78### Classes ###
79
80
81class QuestionAnswerData(typing.TypedDict):
82 question: str
83 answer: str
84 hasNoAnswer: bool
85 cmd: str
86
87
88class RawSearchResultData(typing.TypedDict):
89 messageMatches: list[int]
90 entityMatches: list[int]
91 topicMatches: list[int]
92 actionMatches: list[int]
93
94
95class SearchResultData(typing.TypedDict):
96 searchText: str
97 searchQueryExpr: dict[str, typing.Any] # Serialized search_query_schema.SearchQuery
98 compiledQueryExpr: list[dict[str, typing.Any]] # list[search.SearchQueryExpr]
99 results: list[RawSearchResultData]
100
101
102@dataclass
103class ProcessingContext:
104 query_context: query.QueryEvalContext
105 ar_list: list[QuestionAnswerData]
106 sr_list: list[SearchResultData]
107 ar_index: dict[str, QuestionAnswerData]
108 sr_index: dict[str, SearchResultData]
109 debug1: typing.Literal["none", "diff", "full", "skip"]
110 debug2: typing.Literal["none", "diff", "full", "skip"]
111 debug3: typing.Literal["none", "diff", "full", "nice"]
112 debug4: typing.Literal["none", "diff", "full", "nice"]
113 embedding_model: embeddings.AsyncEmbeddingModel
114 query_translator: typechat.TypeChatJsonTranslator[search_query_schema.SearchQuery]
115 answer_translator: typechat.TypeChatJsonTranslator[
116 answer_response_schema.AnswerResponse
117 ]
118 lang_search_options: searchlang.LanguageSearchOptions
119 answer_context_options: answers.AnswerContextOptions
120
121 def __repr__(self) -> str:
122 parts = []
123 parts.append(f"ar_list={len(self.ar_list)}")
124 parts.append(f"sr_list={len(self.sr_list)}")
125 parts.append(f"ar_index={len(self.ar_index)}")
126 parts.append(f"sr_index={len(self.sr_index)}")
127 parts.append(f"debug1={self.debug1}")
128 parts.append(f"debug2={self.debug2}")
129 parts.append(f"debug3={self.debug3}")
130 parts.append(f"debug4={self.debug4}")
131 parts.append(f"lang_search_options={self.lang_search_options}")
132 parts.append(f"answer_context_options={self.answer_context_options}")
133 return f"Context({', '.join(parts)})"
134
135
136### Main logic ###
137
138
139async def main():
140 utils.load_dotenv()
141 colorama_init(autoreset=True)
142
143 parser = make_arg_parser("TypeAgent Query Tool")
144 args = parser.parse_args()
145 fill_in_debug_defaults(parser, args)
146 if args.logfire:
147 setup_logfire()
148 settings = importing.ConversationSettings()
149 query_context = await load_podcast_index(args.podcast, settings, args.sqlite_db)
150 ar_list, ar_index = load_index_file(args.qafile, "question", QuestionAnswerData)
151 sr_list, sr_index = load_index_file(args.srfile, "searchText", SearchResultData)
152
153 model = convknowledge.create_typechat_model()
154 query_translator = utils.create_translator(model, search_query_schema.SearchQuery)
155 if args.alt_schema:
156 print(f"Substituting alt schema from {args.alt_schema}")
157 with open(args.alt_schema) as f:
158 query_translator.schema_str = f.read()
159 if args.show_schema:
160 print(Fore.YELLOW + query_translator.schema_str.rstrip() + Fore.RESET)
161
162 answer_translator = utils.create_translator(
163 model, answer_response_schema.AnswerResponse
164 )
165
166 context = ProcessingContext(
167 query_context,
168 ar_list,
169 sr_list,
170 ar_index,
171 sr_index,
172 args.debug1,
173 args.debug2,
174 args.debug3,
175 args.debug4,
176 settings.embedding_model,
177 query_translator,
178 answer_translator,
179 searchlang.LanguageSearchOptions(
180 compile_options=searchlang.LanguageQueryCompileOptions(
181 exact_scope=False, verb_scope=True, term_filter=None, apply_scope=True
182 ),
183 exact_match=False,
184 max_message_matches=25,
185 ),
186 answers.AnswerContextOptions(
187 entities_top_k=50, topics_top_k=50, messages_top_k=None, chunking=None
188 ),
189 )
190
191 utils.pretty_print(context, Fore.BLUE, Fore.RESET)
192
193 if args.batch:
194 print(
195 Fore.YELLOW
196 + f"Running in batch mode [{args.offset}:{args.offset + args.limit if args.limit else ''}]."
197 + Fore.RESET
198 )
199 await batch_loop(context, args.offset, args.limit)
200 else:
201 print(Fore.YELLOW + "Running in interactive mode." + Fore.RESET)
202 await interactive_loop(context)
203
204
205async def batch_loop(context: ProcessingContext, offset: int, limit: int) -> None:
206 if limit == 0:
207 limit = len(context.ar_list) - offset
208 sublist = context.ar_list[offset : offset + limit]
209 all_scores = []
210 for counter, qadata in enumerate(sublist, offset + 1):
211 question = qadata["question"]
212 print("-" * 20, counter, question, "-" * 20)
213 score = await process_query(context, question)
214 if score is not None:
215 all_scores.append((score, counter))
216 if not all_scores:
217 return
218 print("=" * 50)
219 all_scores.sort(reverse=True)
220 good_scores = [(score, counter) for score, counter in all_scores if score >= 0.97]
221 bad_scores = [(score, counter) for score, counter in all_scores if score < 0.97]
222 for label, pairs in [("Good", good_scores), ("Bad", bad_scores)]:
223 print(f"{label} scores ({len(pairs)}):")
224 for i in range(0, len(pairs), 10):
225 print(
226 ", ".join(
227 f"{score:.3f}({counter})" for score, counter in pairs[i : i + 10]
228 )
229 )
230
231
232async def interactive_loop(context: ProcessingContext) -> None:
233 if not sys.stdin.isatty():
234 for line in sys.stdin:
235 line = line.strip()
236 if not line:
237 continue
238 await process_query(context, line)
239 return
240
241 print(f"TypeAgent demo UI {__version__} (type 'q' to exit)")
242 if readline:
243 try:
244 readline.read_history_file(".ui_history")
245 except FileNotFoundError:
246 pass # Ignore if history file does not exist.
247
248 try:
249 while True:
250 try:
251 line = input("TypeAgent> ").strip()
252 except EOFError:
253 print()
254 break
255 if not line:
256 continue
257 if line.lower() in ("exit", "quit", "q"):
258 if readline:
259 readline.remove_history_item(
260 readline.get_current_history_length() - 1
261 )
262 break
263 prsep()
264 await process_query(context, line)
265
266 finally:
267 if readline:
268 readline.write_history_file(".ui_history")
269
270
271### Query processing logic ###
272
273
274async def process_query(context: ProcessingContext, query_text: str) -> float | None:
275 record = context.sr_index.get(query_text)
276 debug_context = searchlang.LanguageSearchDebugContext()
277 if context.debug1 == "skip" or context.debug2 == "skip":
278 if not record or (
279 "searchQueryExpr" not in record or "compiledQueryExpr" not in record
280 ):
281 print("Can't skip stages 1 or 2, no precomputed outcomes found.")
282 else:
283 # Skipping stage 2 implies skipping stage 1, and we must supply the
284 # precomputed results for both stages.
285 debug_context.use_search_query = serialization.deserialize_object(
286 search_query_schema.SearchQuery, record["searchQueryExpr"]
287 )
288 print("Skipping stage 1, substituting precomputed search query.")
289 if context.debug2 == "skip":
290 debug_context.use_compiled_search_query_exprs = (
291 serialization.deserialize_object(
292 list[search.SearchQueryExpr],
293 record["compiledQueryExpr"],
294 )
295 )
296 print(
297 "Skipping stage 2, substituting precomputed compiled query expressions."
298 )
299 prsep()
300
301 result = await searchlang.search_conversation_with_language(
302 context.query_context.conversation,
303 context.query_translator,
304 query_text,
305 context.lang_search_options,
306 debug_context=debug_context,
307 )
308 if isinstance(result, typechat.Failure):
309 print("Stages 1-3 failed:")
310 print(Fore.RED + str(result) + Fore.RESET)
311 return
312 search_results = result.value
313
314 actual1 = debug_context.search_query
315 if actual1:
316 if context.debug1 == "full":
317 print("Stage 1 results:")
318 utils.pretty_print(actual1, Fore.GREEN, Fore.RESET)
319 prsep()
320 elif context.debug1 == "diff":
321 if record and "searchQueryExpr" in record:
322 print("Stage 1 diff:")
323 expected1 = serialization.deserialize_object(
324 search_query_schema.SearchQuery, record["searchQueryExpr"]
325 )
326 compare_and_print_diff(expected1, actual1)
327 else:
328 print("Stage 1 diff unavailable")
329 prsep()
330
331 actual2 = debug_context.search_query_expr
332 if actual2:
333 if context.debug2 == "full":
334 print("Stage 2 results:")
335 utils.pretty_print(actual2, Fore.GREEN, Fore.RESET)
336 prsep()
337 elif context.debug2 == "diff":
338 if record and "compiledQueryExpr" in record:
339 print("Stage 2 diff:")
340 expected2 = serialization.deserialize_object(
341 list[search.SearchQueryExpr], record["compiledQueryExpr"]
342 )
343 compare_and_print_diff(expected2, actual2)
344 else:
345 print("Stage 2 diff unavailable")
346 prsep()
347
348 actual3 = search_results
349 if context.debug3 == "full":
350 print("Stage 3 full results:")
351 utils.pretty_print(actual3, Fore.GREEN, Fore.RESET)
352 prsep()
353 elif context.debug3 == "nice":
354 print("Stage 3 nice results:")
355 for sr in search_results:
356 await print_result(sr, context.query_context.conversation)
357 prsep()
358 elif context.debug3 == "diff":
359 if record and "results" in record:
360 print("Stage 3 diff:")
361 expected3: list[RawSearchResultData] = record["results"]
362 compare_results(expected3, actual3)
363 else:
364 print("Stage 3 diff unavailable")
365 prsep()
366
367 all_answers, combined_answer = await answers.generate_answers(
368 context.answer_translator,
369 search_results,
370 context.query_context.conversation,
371 query_text,
372 options=context.answer_context_options,
373 )
374
375 if context.debug4 == "full":
376 utils.pretty_print(all_answers)
377 prsep()
378 if context.debug4 in ("full", "nice"):
379 if combined_answer.type == "NoAnswer":
380 print(Fore.RED + f"Failure: {combined_answer.whyNoAnswer}" + Fore.RESET)
381 else:
382 print(Fore.GREEN + f"{combined_answer.answer}" + Fore.RESET)
383 prsep()
384 elif context.debug4 == "diff":
385 if query_text in context.ar_index:
386 record = context.ar_index[query_text]
387 expected4: tuple[str, bool] = (record["answer"], not record["hasNoAnswer"])
388 print("Stage 4 diff:")
389 match combined_answer.type:
390 case "NoAnswer":
391 actual4 = (combined_answer.whyNoAnswer or "", False)
392 case "Answered":
393 actual4 = (combined_answer.answer or "", True)
394 score = await compare_answers(context, expected4, actual4)
395 if actual4[0].startswith("TypeChat failure:"):
396 print(Fore.YELLOW + "No answer received" + Fore.RESET)
397 else:
398 print(f"Score: {score:.3f}; Question: {query_text}")
399 return score
400 else:
401 print("Stage 4 diff unavailable; nice answer:")
402 if combined_answer.type == "NoAnswer":
403 print(Fore.RED + f"Failure: {combined_answer.whyNoAnswer}" + Fore.RESET)
404 else:
405 print(Fore.GREEN + f"{combined_answer.answer}" + Fore.RESET)
406 prsep()
407
408
409def prsep():
410 print("-" * 50)
411
412
413### CLI processing ###
414
415
416def make_arg_parser(description: str) -> argparse.ArgumentParser:
417 line_width = utils.cap(144, shutil.get_terminal_size().columns)
418 parser = argparse.ArgumentParser(
419 description=description,
420 formatter_class=lambda *a, **b: argparse.HelpFormatter(
421 *a, **b, max_help_position=35 if line_width >= 100 else 28, width=line_width
422 ),
423 )
424
425 default_podcast_file = "testdata/Episode_53_AdrianTchaikovsky_index"
426 parser.add_argument(
427 "--podcast",
428 type=str,
429 default=default_podcast_file,
430 help="Path to the podcast index files (excluding the '_index.json' suffix)",
431 )
432 default_qafile = "testdata/Episode_53_Answer_results.json"
433 explain_qa = "a list of questions and answers to test the full pipeline"
434 parser.add_argument(
435 "--qafile",
436 type=str,
437 default=default_qafile,
438 help=f"Path to the Answer_results.json file ({explain_qa})",
439 )
440 default_srfile = "testdata/Episode_53_Search_results.json"
441 explain_sr = "a list of intermediate results from stages 1, 2 and 3"
442 parser.add_argument(
443 "--srfile",
444 type=str,
445 default=default_srfile,
446 help=f"Path to the Search_results.json file ({explain_sr})",
447 )
448 parser.add_argument(
449 "--sqlite-db",
450 type=str,
451 default=None,
452 help="Path to the SQLite database file (default: no SQLite)",
453 )
454
455 batch = parser.add_argument_group("Batch mode options")
456 batch.add_argument(
457 "--batch",
458 action="store_true",
459 help="Run in batch mode, suppressing interactive prompts.",
460 )
461 batch.add_argument(
462 "--offset",
463 type=int,
464 default=0,
465 help="Number of initial Q/A pairs to skip (default none)",
466 )
467 batch.add_argument(
468 "--limit",
469 type=int,
470 default=0,
471 help="Number of Q/A pairs to process (default all)",
472 )
473 batch.add_argument(
474 "--start",
475 type=int,
476 default=0,
477 help="Do just this question (similar to --offset START-1 --limit 1)",
478 )
479
480 debug = parser.add_argument_group("Debug options")
481 debug.add_argument(
482 "--debug",
483 type=str,
484 default=None,
485 choices=["none", "diff", "full"],
486 help="Default debug level: 'none' for no debug output, 'diff' for diff output, "
487 "'full' for full debug output.",
488 )
489 arg_helper = lambda key: typing.get_args(ProcessingContext.__annotations__[key])
490 debug.add_argument(
491 "--debug1",
492 type=str,
493 default=None,
494 choices=arg_helper("debug1"),
495 help="Debug level override for stage 1: like --debug; or 'skip' to skip stage 1.",
496 )
497 debug.add_argument(
498 "--debug2",
499 type=str,
500 default=None,
501 choices=arg_helper("debug2"),
502 help="Debug level override for stage 2: like --debug; or 'skip' to skip stages 1-2.",
503 )
504 debug.add_argument(
505 "--debug3",
506 type=str,
507 default=None,
508 choices=arg_helper("debug3"),
509 help="Debug level override for stage 3: like --debug; or 'nice' to print answer only.",
510 )
511 debug.add_argument(
512 "--debug4",
513 type=str,
514 default=None,
515 choices=arg_helper("debug4"),
516 help="Debug level override for stage 4: like --debug; or 'nice' to print answer only.",
517 )
518 debug.add_argument(
519 "--alt-schema",
520 type=str,
521 default=None,
522 help="Path to alternate schema file for query translator (modifies stage 1).",
523 )
524 debug.add_argument(
525 "--show-schema",
526 action="store_true",
527 help="Show the TypeScript schema computed by typechat.",
528 )
529 debug.add_argument(
530 "--logfire",
531 action="store_true",
532 help="Upload log events to Pydantic's Logfire server",
533 )
534
535 return parser
536
537
538def fill_in_debug_defaults(
539 parser: argparse.ArgumentParser, args: argparse.Namespace
540) -> None:
541 # In batch mode, defaults are diff, diff, diff, diff.
542 # In interactive mode they are none, none, none, nice.
543 if not args.batch:
544 if args.start or args.offset or args.limit:
545 parser.exit(2, "Error: --start, --offset and --limit require --batch\n")
546 else:
547 if args.start:
548 if args.offset != 0:
549 parser.exit(2, "Error: --start and --offset can't be both set\n")
550 args.offset = args.start - 1
551 if args.limit == 0:
552 args.limit = 1
553 args.debug = args.debug or "diff"
554
555 args.debug1 = args.debug1 or args.debug or "none"
556 args.debug2 = args.debug2 or args.debug or "none"
557 args.debug3 = args.debug3 or args.debug or "none"
558 args.debug4 = args.debug4 or args.debug or "nice"
559 if args.debug2 == "skip":
560 args.debug1 = "skip" # Skipping stage 2 implies skipping stage 1.
561
562
563### Data loading ###
564
565
566async def load_podcast_index(
567 podcast_file_prefix: str,
568 settings: importing.ConversationSettings,
569 dbname: str | None,
570) -> query.QueryEvalContext:
571 with utils.timelog(f"load podcast from {podcast_file_prefix!r}"):
572 conversation = await podcast.Podcast.read_from_file(
573 podcast_file_prefix, settings, dbname
574 )
575 assert (
576 conversation is not None
577 ), f"Failed to load podcast from {podcast_file_prefix!r}"
578 return query.QueryEvalContext(conversation)
579
580
581def load_index_file[T: Mapping[str, typing.Any]](
582 file: str, selector: str, cls: type[T]
583) -> tuple[list[T], dict[str, T]]:
584 # If this crashes, the file is malformed -- go figure it out.
585 try:
586 with open(file) as f:
587 lst: list[T] = json.load(f)
588 except FileNotFoundError as err:
589 print(Fore.RED + str(err) + Fore.RESET)
590 lst = []
591 index = {item[selector]: item for item in lst}
592 if len(index) != len(lst):
593 print(f"{len(lst) - len(index)} duplicate items found in {file!r}. ")
594 return lst, index
595
596
597### Debug output ###
598
599
600async def print_result[TMessage: IMessage, TIndex: ITermToSemanticRefIndex](
601 result: search.ConversationSearchResult,
602 conversation: IConversation[TMessage, TIndex],
603) -> None:
604 print(
605 f"Raw query: {result.raw_query_text};",
606 f"{len(result.message_matches)} message matches,",
607 f"{len(result.knowledge_matches)} knowledge matches",
608 )
609 if result.message_matches:
610 print("Message matches:")
611 for scored_ord in sorted(
612 result.message_matches, key=lambda x: x.score, reverse=True
613 ):
614 score = scored_ord.score
615 msg_ord = scored_ord.message_ordinal
616 msg = await conversation.messages.get_item(msg_ord)
617 assert msg.metadata is not None # For type checkers
618 text = " ".join(msg.text_chunks).strip()
619 print(
620 f"({score:5.1f}) M={msg_ord:d}: "
621 f"{msg.metadata.source!s:>15.15s}: "
622 f"{repr(text)[1:-1]:<150.150s} "
623 )
624 if result.knowledge_matches:
625 print(f"Knowledge matches ({', '.join(sorted(result.knowledge_matches))}):")
626 for key, value in sorted(result.knowledge_matches.items()):
627 print(f"Type {key} -- {value.term_matches}:")
628 for scored_sem_ref_ord in value.semantic_ref_matches:
629 score = scored_sem_ref_ord.score
630 sem_ref_ord = scored_sem_ref_ord.semantic_ref_ordinal
631 if conversation.semantic_refs is None:
632 print(f" Ord: {sem_ref_ord} (score {score})")
633 else:
634 sem_ref = await conversation.semantic_refs.get_item(sem_ref_ord)
635 msg_ord = sem_ref.range.start.message_ordinal
636 chunk_ord = sem_ref.range.start.chunk_ordinal
637 msg = await conversation.messages.get_item(msg_ord)
638 print(
639 f"({score:5.1f}) M={msg_ord}: "
640 f"S={summarize_knowledge(sem_ref)}"
641 )
642
643
644def summarize_knowledge(sem_ref: SemanticRef) -> str:
645 """Summarize the knowledge in a SemanticRef."""
646 knowledge = sem_ref.knowledge
647 if knowledge is None:
648 return f"{sem_ref.semantic_ref_ordinal}: <No knowledge>"
649 match sem_ref.knowledge_type:
650 case "entity":
651 entity = knowledge
652 assert isinstance(entity, kplib.ConcreteEntity)
653 res = [f"{entity.name} [{', '.join(entity.type)}]"]
654 if entity.facets:
655 for facet in entity.facets:
656 value = facet.value
657 if isinstance(value, kplib.Quantity):
658 value = f"{value.amount} {value.units}"
659 elif isinstance(value, float) and value.is_integer():
660 value = int(value)
661 res.append(f"<{facet.name}:{value}>")
662 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
663 case "action":
664 action = knowledge
665 assert isinstance(action, kplib.Action)
666 res = []
667 res.append("/".join(repr(verb) for verb in action.verbs))
668 if action.verb_tense:
669 res.append(f"[{action.verb_tense}]")
670 if action.subject_entity_name != "none":
671 res.append(f"subj={action.subject_entity_name!r}")
672 if action.object_entity_name != "none":
673 res.append(f"obj={action.object_entity_name!r}")
674 if action.indirect_object_entity_name != "none":
675 res.append(f"ind_obj={action.indirect_object_entity_name}")
676 if action.params:
677 for param in action.params:
678 if isinstance(param, kplib.ActionParam):
679 res.append(f"<{param.name}:{param.value}>")
680 else:
681 res.append(f"<{param}>")
682 if action.subject_entity_facet is not None:
683 res.append(f"subj_facet={action.subject_entity_facet}")
684 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
685 case "topic":
686 topic = knowledge
687 assert isinstance(topic, Topic)
688 return f"{sem_ref.semantic_ref_ordinal}: {topic.text!r}]"
689 case "tag":
690 tag = knowledge
691 assert isinstance(tag, str)
692 return f"{sem_ref.semantic_ref_ordinal}: #{tag!r}"
693 case _:
694 return f"{sem_ref.semantic_ref_ordinal}: {sem_ref.knowledge!r}"
695
696
697def compare_results(
698 matches_records: list[RawSearchResultData],
699 results: list[search.ConversationSearchResult],
700) -> bool:
701 if len(results) != len(matches_records):
702 print(f"(Result sizes mismatch, {len(results)} != {len(matches_records)})")
703 return False
704 res = True
705 for result, record in zip(results, matches_records):
706 if not compare_message_ordinals(
707 result.message_matches, record["messageMatches"]
708 ):
709 res = False
710 if not compare_semantic_ref_ordinals(
711 (
712 []
713 if "entity" not in result.knowledge_matches
714 else result.knowledge_matches["entity"].semantic_ref_matches
715 ),
716 record.get("entityMatches", []),
717 "entity",
718 ):
719 res = False
720 if not compare_semantic_ref_ordinals(
721 (
722 []
723 if "action" not in result.knowledge_matches
724 else result.knowledge_matches["action"].semantic_ref_matches
725 ),
726 record.get("actionMatches", []),
727 "action",
728 ):
729 res = False
730 if not compare_semantic_ref_ordinals(
731 (
732 []
733 if "topic" not in result.knowledge_matches
734 else result.knowledge_matches["topic"].semantic_ref_matches
735 ),
736 record.get("topicMatches", []),
737 "topic",
738 ):
739 res = False
740 return res
741
742
743# Special case: In the Podcast, these messages are all Kevin saying "Yeah",
744# so if the difference is limited to these, we consider it a match.
745NOISE_MESSAGES = frozenset({42, 46, 52, 68, 70})
746
747
748def compare_message_ordinals(aa: list[ScoredMessageOrdinal], b: list[int]) -> bool:
749 a = [aai.message_ordinal for aai in aa]
750 if set(a) ^ set(b) <= NOISE_MESSAGES:
751 return True
752 print("Message ordinals do not match:")
753 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
754 return False
755
756
757def compare_semantic_ref_ordinals(
758 aa: list[ScoredSemanticRefOrdinal], b: list[int], label: str
759) -> bool:
760 a = [aai.semantic_ref_ordinal for aai in aa]
761 if sorted(a) == sorted(b):
762 return True
763 print(f"{label.capitalize()} SemanticRef ordinals do not match:")
764 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
765 return False
766
767
768def compare_and_print_diff(a: object, b: object) -> bool: # True if equal
769 """Diff two objects whose repr() is a valid Python expression."""
770 if a == b:
771 return True
772 a_repr = repr(a)
773 b_repr = repr(b)
774 if a_repr == b_repr:
775 return True
776 # Shorten floats so slight differences in score etc. don't cause false positives.
777 a_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", a_repr)
778 b_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", b_repr)
779 if a_repr == b_repr:
780 return True
781 a_formatted = utils.format_code(a_repr)
782 b_formatted = utils.format_code(b_repr)
783 print_diff(a_formatted, b_formatted, n=2)
784 return False
785
786
787async def compare_answers(
788 context: ProcessingContext, expected: tuple[str, bool], actual: tuple[str, bool]
789) -> float:
790 expected_text, expected_success = expected
791 actual_text, actual_success = actual
792
793 if expected_success != actual_success:
794 print(
795 f"Expected success: {Fore.RED}{expected_success}{Fore.RESET}; "
796 f"actual: {Fore.GREEN}{actual_success}{Fore.RESET}"
797 )
798
799 elif not actual_success:
800 print(Fore.GREEN + f"Both failed" + Fore.RESET)
801 return 1.001
802
803 elif expected_text == actual_text:
804 print(Fore.GREEN + f"Both equal" + Fore.RESET)
805 return 1.000
806
807 if len(expected_text.splitlines()) <= 100 and len(actual_text.splitlines()) <= 100:
808 n = 100
809 else:
810 n = 2
811 print_diff(expected_text, actual_text, n=n)
812
813 if expected_success != actual_success:
814 return 0.000 if expected_success else 0.001 # 0.001 == Answer not expected
815 else:
816 return await equality_score(context, expected_text, actual_text)
817
818
819def print_diff(a: str, b: str, n: int) -> None:
820 diff = difflib.unified_diff(
821 a.splitlines(),
822 b.splitlines(),
823 fromfile="expected",
824 tofile="actual",
825 n=n,
826 )
827 for x in diff:
828 if x.startswith("-"):
829 print(Fore.RED + x.rstrip("\n") + Fore.RESET)
830 elif x.startswith("+"):
831 print(Fore.GREEN + x.rstrip("\n") + Fore.RESET)
832 else:
833 print(x.rstrip("\n"))
834
835
836async def equality_score(context: ProcessingContext, a: str, b: str) -> float:
837 if a == b:
838 return 1.0
839 if a.lower() == b.lower():
840 return 0.999
841 embeddings = await context.embedding_model.get_embeddings([a, b])
842 assert embeddings.shape[0] == 2, "Expected two embeddings"
843 return np.dot(embeddings[0], embeddings[1])
844
845
846### Run main ###
847
848if __name__ == "__main__":
849 try:
850 asyncio.run(main())
851 except (KeyboardInterrupt, BrokenPipeError):
852 print()
853 sys.exit(1)