microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
5909ca10e80336b1db01ed84fa54a1fbb3181b57

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

840lines · 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
139def 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 = load_podcast_index(args.podcast, settings)
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 asyncio.run(batch_loop(context, args.offset, args.limit))
200 else:
201 print(Fore.YELLOW + "Running in interactive mode." + Fore.RESET)
202 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
232def 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 asyncio.run(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 asyncio.run(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 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 print(f"Score: {score:.3f}; Question: {query_text}")
396 return score
397 else:
398 print("Stage 4 diff unavailable; nice answer:")
399 if combined_answer.type == "NoAnswer":
400 print(Fore.RED + f"Failure: {combined_answer.whyNoAnswer}" + Fore.RESET)
401 else:
402 print(Fore.GREEN + f"{combined_answer.answer}" + Fore.RESET)
403 prsep()
404
405
406def prsep():
407 print("-" * 50)
408
409
410### CLI processing ###
411
412
413def make_arg_parser(description: str) -> argparse.ArgumentParser:
414 line_width = utils.cap(144, shutil.get_terminal_size().columns)
415 parser = argparse.ArgumentParser(
416 description=description,
417 formatter_class=lambda *a, **b: argparse.HelpFormatter(
418 *a, **b, max_help_position=35 if line_width >= 100 else 28, width=line_width
419 ),
420 )
421
422 default_podcast_file = "testdata/Episode_53_AdrianTchaikovsky_index"
423 parser.add_argument(
424 "--podcast",
425 type=str,
426 default=default_podcast_file,
427 help="Path to the podcast index files (excluding the '_index.json' suffix)",
428 )
429 default_qafile = "testdata/Episode_53_Answer_results.json"
430 explain_qa = "a list of questions and answers to test the full pipeline"
431 parser.add_argument(
432 "--qafile",
433 type=str,
434 default=default_qafile,
435 help=f"Path to the Answer_results.json file ({explain_qa})",
436 )
437 default_srfile = "testdata/Episode_53_Search_results.json"
438 explain_sr = "a list of intermediate results from stages 1, 2 and 3"
439 parser.add_argument(
440 "--srfile",
441 type=str,
442 default=default_srfile,
443 help=f"Path to the Search_results.json file ({explain_sr})",
444 )
445
446 batch = parser.add_argument_group("Batch mode options")
447 batch.add_argument(
448 "--batch",
449 action="store_true",
450 help="Run in batch mode, suppressing interactive prompts.",
451 )
452 batch.add_argument(
453 "--offset",
454 type=int,
455 default=0,
456 help="Number of initial Q/A pairs to skip (default none)",
457 )
458 batch.add_argument(
459 "--limit",
460 type=int,
461 default=0,
462 help="Number of Q/A pairs to process (default all)",
463 )
464 batch.add_argument(
465 "--start",
466 type=int,
467 default=0,
468 help="Do just this question (similar to --offset START-1 --limit 1)",
469 )
470
471 debug = parser.add_argument_group("Debug options")
472 debug.add_argument(
473 "--debug",
474 type=str,
475 default=None,
476 choices=["none", "diff", "full"],
477 help="Default debug level: 'none' for no debug output, 'diff' for diff output, "
478 "'full' for full debug output.",
479 )
480 arg_helper = lambda key: typing.get_args(ProcessingContext.__annotations__[key])
481 debug.add_argument(
482 "--debug1",
483 type=str,
484 default=None,
485 choices=arg_helper("debug1"),
486 help="Debug level override for stage 1: like --debug; or 'skip' to skip stage 1.",
487 )
488 debug.add_argument(
489 "--debug2",
490 type=str,
491 default=None,
492 choices=arg_helper("debug2"),
493 help="Debug level override for stage 2: like --debug; or 'skip' to skip stages 1-2.",
494 )
495 debug.add_argument(
496 "--debug3",
497 type=str,
498 default=None,
499 choices=arg_helper("debug3"),
500 help="Debug level override for stage 3: like --debug; or 'nice' to print answer only.",
501 )
502 debug.add_argument(
503 "--debug4",
504 type=str,
505 default=None,
506 choices=arg_helper("debug4"),
507 help="Debug level override for stage 4: like --debug; or 'nice' to print answer only.",
508 )
509 debug.add_argument(
510 "--alt-schema",
511 type=str,
512 default=None,
513 help="Path to alternate schema file for query translator (modifies stage 1).",
514 )
515 debug.add_argument(
516 "--show-schema",
517 action="store_true",
518 help="Show the TypeScript schema computed by typechat.",
519 )
520 debug.add_argument(
521 "--logfire",
522 action="store_true",
523 help="Upload log events to Pydantic's Logfire server",
524 )
525
526 return parser
527
528
529def fill_in_debug_defaults(
530 parser: argparse.ArgumentParser, args: argparse.Namespace
531) -> None:
532 # In batch mode, defaults are diff, diff, diff, diff.
533 # In interactive mode they are none, none, none, nice.
534 if not args.batch:
535 if args.start or args.offset or args.limit:
536 parser.exit(2, "Error: --start, --offset and --limit require --batch\n")
537 else:
538 if args.start:
539 if args.offset != 0:
540 parser.exit(2, "Error: --start and --offset can't be both set\n")
541 args.offset = args.start - 1
542 if args.limit == 0:
543 args.limit = 1
544 args.debug = args.debug or "diff"
545
546 args.debug1 = args.debug1 or args.debug or "none"
547 args.debug2 = args.debug2 or args.debug or "none"
548 args.debug3 = args.debug3 or args.debug or "none"
549 args.debug4 = args.debug4 or args.debug or "nice"
550 if args.debug2 == "skip":
551 args.debug1 = "skip" # Skipping stage 2 implies skipping stage 1.
552
553
554### Data loading ###
555
556
557def load_podcast_index(
558 podcast_file_prefix: str, settings: importing.ConversationSettings
559) -> query.QueryEvalContext:
560 with utils.timelog(f"load podcast from {podcast_file_prefix!r}"):
561 conversation = podcast.Podcast.read_from_file(podcast_file_prefix, settings)
562 assert (
563 conversation is not None
564 ), f"Failed to load podcast from {podcast_file_prefix!r}"
565 return query.QueryEvalContext(conversation)
566
567
568def load_index_file[T: Mapping[str, typing.Any]](
569 file: str, selector: str, cls: type[T]
570) -> tuple[list[T], dict[str, T]]:
571 # If this crashes, the file is malformed -- go figure it out.
572 try:
573 with open(file) as f:
574 lst: list[T] = json.load(f)
575 except FileNotFoundError as err:
576 print(Fore.RED + str(err) + Fore.RESET)
577 lst = []
578 index = {item[selector]: item for item in lst}
579 if len(index) != len(lst):
580 print(f"{len(lst) - len(index)} duplicate items found in {file!r}. ")
581 return lst, index
582
583
584### Debug output ###
585
586
587def print_result[TMessage: IMessage, TIndex: ITermToSemanticRefIndex](
588 result: search.ConversationSearchResult,
589 conversation: IConversation[TMessage, TIndex],
590) -> None:
591 print(
592 f"Raw query: {result.raw_query_text};",
593 f"{len(result.message_matches)} message matches,",
594 f"{len(result.knowledge_matches)} knowledge matches",
595 )
596 if result.message_matches:
597 print("Message matches:")
598 for scored_ord in sorted(
599 result.message_matches, key=lambda x: x.score, reverse=True
600 ):
601 score = scored_ord.score
602 msg_ord = scored_ord.message_ordinal
603 msg = conversation.messages[msg_ord]
604 assert msg.metadata is not None # For type checkers
605 text = " ".join(msg.text_chunks).strip()
606 print(
607 f"({score:5.1f}) M={msg_ord:d}: "
608 f"{msg.metadata.source!s:>15.15s}: "
609 f"{repr(text)[1:-1]:<150.150s} "
610 )
611 if result.knowledge_matches:
612 print(f"Knowledge matches ({', '.join(sorted(result.knowledge_matches))}):")
613 for key, value in sorted(result.knowledge_matches.items()):
614 print(f"Type {key} -- {value.term_matches}:")
615 for scored_sem_ref_ord in value.semantic_ref_matches:
616 score = scored_sem_ref_ord.score
617 sem_ref_ord = scored_sem_ref_ord.semantic_ref_ordinal
618 if conversation.semantic_refs is None:
619 print(f" Ord: {sem_ref_ord} (score {score})")
620 else:
621 sem_ref = conversation.semantic_refs[sem_ref_ord]
622 msg_ord = sem_ref.range.start.message_ordinal
623 chunk_ord = sem_ref.range.start.chunk_ordinal
624 msg = conversation.messages[msg_ord]
625 print(
626 f"({score:5.1f}) M={msg_ord}: "
627 f"S={summarize_knowledge(sem_ref)}"
628 )
629
630
631def summarize_knowledge(sem_ref: SemanticRef) -> str:
632 """Summarize the knowledge in a SemanticRef."""
633 knowledge = sem_ref.knowledge
634 if knowledge is None:
635 return f"{sem_ref.semantic_ref_ordinal}: <No knowledge>"
636 match sem_ref.knowledge_type:
637 case "entity":
638 entity = knowledge
639 assert isinstance(entity, kplib.ConcreteEntity)
640 res = [f"{entity.name} [{', '.join(entity.type)}]"]
641 if entity.facets:
642 for facet in entity.facets:
643 value = facet.value
644 if isinstance(value, kplib.Quantity):
645 value = f"{value.amount} {value.units}"
646 elif isinstance(value, float) and value.is_integer():
647 value = int(value)
648 res.append(f"<{facet.name}:{value}>")
649 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
650 case "action":
651 action = knowledge
652 assert isinstance(action, kplib.Action)
653 res = []
654 res.append("/".join(repr(verb) for verb in action.verbs))
655 if action.verb_tense:
656 res.append(f"[{action.verb_tense}]")
657 if action.subject_entity_name != "none":
658 res.append(f"subj={action.subject_entity_name!r}")
659 if action.object_entity_name != "none":
660 res.append(f"obj={action.object_entity_name!r}")
661 if action.indirect_object_entity_name != "none":
662 res.append(f"ind_obj={action.indirect_object_entity_name}")
663 if action.params:
664 for param in action.params:
665 if isinstance(param, kplib.ActionParam):
666 res.append(f"<{param.name}:{param.value}>")
667 else:
668 res.append(f"<{param}>")
669 if action.subject_entity_facet is not None:
670 res.append(f"subj_facet={action.subject_entity_facet}")
671 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
672 case "topic":
673 topic = knowledge
674 assert isinstance(topic, Topic)
675 return f"{sem_ref.semantic_ref_ordinal}: {topic.text!r}]"
676 case "tag":
677 tag = knowledge
678 assert isinstance(tag, str)
679 return f"{sem_ref.semantic_ref_ordinal}: #{tag!r}"
680 case _:
681 return f"{sem_ref.semantic_ref_ordinal}: {sem_ref.knowledge!r}"
682
683
684def compare_results(
685 matches_records: list[RawSearchResultData],
686 results: list[search.ConversationSearchResult],
687) -> bool:
688 if len(results) != len(matches_records):
689 print(f"(Result sizes mismatch, {len(results)} != {len(matches_records)})")
690 return False
691 res = True
692 for result, record in zip(results, matches_records):
693 if not compare_message_ordinals(
694 result.message_matches, record["messageMatches"]
695 ):
696 res = False
697 if not compare_semantic_ref_ordinals(
698 (
699 []
700 if "entity" not in result.knowledge_matches
701 else result.knowledge_matches["entity"].semantic_ref_matches
702 ),
703 record.get("entityMatches", []),
704 "entity",
705 ):
706 res = False
707 if not compare_semantic_ref_ordinals(
708 (
709 []
710 if "action" not in result.knowledge_matches
711 else result.knowledge_matches["action"].semantic_ref_matches
712 ),
713 record.get("actionMatches", []),
714 "action",
715 ):
716 res = False
717 if not compare_semantic_ref_ordinals(
718 (
719 []
720 if "topic" not in result.knowledge_matches
721 else result.knowledge_matches["topic"].semantic_ref_matches
722 ),
723 record.get("topicMatches", []),
724 "topic",
725 ):
726 res = False
727 return res
728
729
730# Special case: In the Podcast, these messages are all Kevin saying "Yeah",
731# so if the difference is limited to these, we consider it a match.
732NOISE_MESSAGES = frozenset({42, 46, 52, 68, 70})
733
734
735def compare_message_ordinals(aa: list[ScoredMessageOrdinal], b: list[int]) -> bool:
736 a = [aai.message_ordinal for aai in aa]
737 if set(a) ^ set(b) <= NOISE_MESSAGES:
738 return True
739 print("Message ordinals do not match:")
740 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
741 return False
742
743
744def compare_semantic_ref_ordinals(
745 aa: list[ScoredSemanticRefOrdinal], b: list[int], label: str
746) -> bool:
747 a = [aai.semantic_ref_ordinal for aai in aa]
748 if sorted(a) == sorted(b):
749 return True
750 print(f"{label.capitalize()} SemanticRef ordinals do not match:")
751 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
752 return False
753
754
755def compare_and_print_diff(a: object, b: object) -> bool: # True if equal
756 """Diff two objects whose repr() is a valid Python expression."""
757 if a == b:
758 return True
759 a_repr = repr(a)
760 b_repr = repr(b)
761 if a_repr == b_repr:
762 return True
763 # Shorten floats so slight differences in score etc. don't cause false positives.
764 a_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", a_repr)
765 b_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", b_repr)
766 if a_repr == b_repr:
767 return True
768 a_formatted = utils.format_code(a_repr)
769 b_formatted = utils.format_code(b_repr)
770 print_diff(a_formatted, b_formatted, n=2)
771 return False
772
773
774async def compare_answers(
775 context: ProcessingContext, expected: tuple[str, bool], actual: tuple[str, bool]
776) -> float:
777 expected_text, expected_success = expected
778 actual_text, actual_success = actual
779
780 if expected_success != actual_success:
781 print(
782 f"Expected success: {Fore.RED}{expected_success}{Fore.RESET}; "
783 f"actual: {Fore.GREEN}{actual_success}{Fore.RESET}"
784 )
785
786 elif not actual_success:
787 print(Fore.GREEN + f"Both failed" + Fore.RESET)
788 return 1.001
789
790 elif expected_text == actual_text:
791 print(Fore.GREEN + f"Both equal" + Fore.RESET)
792 return 1.000
793
794 if len(expected_text.splitlines()) <= 100 and len(actual_text.splitlines()) <= 100:
795 n = 100
796 else:
797 n = 2
798 print_diff(expected_text, actual_text, n=n)
799
800 if expected_success != actual_success:
801 return 0.000 if expected_success else 0.001 # 0.001 == Answer not expected
802 else:
803 return await equality_score(context, expected_text, actual_text)
804
805
806def print_diff(a: str, b: str, n: int) -> None:
807 diff = difflib.unified_diff(
808 a.splitlines(),
809 b.splitlines(),
810 fromfile="expected",
811 tofile="actual",
812 n=n,
813 )
814 for x in diff:
815 if x.startswith("-"):
816 print(Fore.RED + x.rstrip("\n") + Fore.RESET)
817 elif x.startswith("+"):
818 print(Fore.GREEN + x.rstrip("\n") + Fore.RESET)
819 else:
820 print(x.rstrip("\n"))
821
822
823async def equality_score(context: ProcessingContext, a: str, b: str) -> float:
824 if a == b:
825 return 1.0
826 if a.lower() == b.lower():
827 return 0.999
828 embeddings = await context.embedding_model.get_embeddings([a, b])
829 assert embeddings.shape[0] == 2, "Expected two embeddings"
830 return np.dot(embeddings[0], embeddings[1])
831
832
833### Run main ###
834
835if __name__ == "__main__":
836 try:
837 main()
838 except (KeyboardInterrupt, BrokenPipeError):
839 print()
840 sys.exit(1)
841