microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
80d39a707b13a12971c408f52399abf5805bdb71

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

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