microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
9c3368256a90c2dc813f407c5ade486c437e1c92

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

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