microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
7e219939f2b9a5f882c137c8246e812fd64a7123

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

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