microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
0267cfb4d29c96205631393810d2314e33aba01c

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

798lines · 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: str, settings: importing.ConversationSettings
525) -> query.QueryEvalContext:
526 with utils.timelog(f"load podcast from {podcast_file!r}"):
527 conversation = podcast.Podcast.read_from_file(podcast_file, settings)
528 assert conversation is not None, f"Failed to load podcast from {podcast_file!r}"
529 return query.QueryEvalContext(conversation)
530
531
532def load_index_file[T: Mapping[str, typing.Any]](
533 file: str, selector: str, cls: type[T]
534) -> tuple[list[T], dict[str, T]]:
535 # If this crashes, the file is malformed -- go figure it out.
536 try:
537 with open(file) as f:
538 lst: list[T] = json.load(f)
539 except FileNotFoundError as err:
540 print(Fore.RED + str(err) + Fore.RESET)
541 lst = []
542 index = {item[selector]: item for item in lst}
543 if len(index) != len(lst):
544 print(f"{len(lst) - len(index)} duplicate items found in {file!r}. ")
545 return lst, index
546
547
548### Debug output ###
549
550
551def print_result[TMessage: IMessage, TIndex: ITermToSemanticRefIndex](
552 result: search.ConversationSearchResult,
553 conversation: IConversation[TMessage, TIndex],
554) -> None:
555 print(
556 f"Raw query: {result.raw_query_text};",
557 f"{len(result.message_matches)} message matches,",
558 f"{len(result.knowledge_matches)} knowledge matches",
559 )
560 if result.message_matches:
561 print("Message matches:")
562 for scored_ord in sorted(
563 result.message_matches, key=lambda x: x.score, reverse=True
564 ):
565 score = scored_ord.score
566 msg_ord = scored_ord.message_ordinal
567 msg = conversation.messages[msg_ord]
568 assert msg.metadata is not None # For type checkers
569 text = " ".join(msg.text_chunks).strip()
570 print(
571 f"({score:5.1f}) M={msg_ord:d}: "
572 f"{msg.metadata.source!s:>15.15s}: "
573 f"{repr(text)[1:-1]:<150.150s} "
574 )
575 if result.knowledge_matches:
576 print(f"Knowledge matches ({', '.join(sorted(result.knowledge_matches))}):")
577 for key, value in sorted(result.knowledge_matches.items()):
578 print(f"Type {key} -- {value.term_matches}:")
579 for scored_sem_ref_ord in value.semantic_ref_matches:
580 score = scored_sem_ref_ord.score
581 sem_ref_ord = scored_sem_ref_ord.semantic_ref_ordinal
582 if conversation.semantic_refs is None:
583 print(f" Ord: {sem_ref_ord} (score {score})")
584 else:
585 sem_ref = conversation.semantic_refs[sem_ref_ord]
586 msg_ord = sem_ref.range.start.message_ordinal
587 chunk_ord = sem_ref.range.start.chunk_ordinal
588 msg = conversation.messages[msg_ord]
589 print(
590 f"({score:5.1f}) M={msg_ord}: "
591 f"S={summarize_knowledge(sem_ref)}"
592 )
593
594
595def summarize_knowledge(sem_ref: SemanticRef) -> str:
596 """Summarize the knowledge in a SemanticRef."""
597 knowledge = sem_ref.knowledge
598 if knowledge is None:
599 return f"{sem_ref.semantic_ref_ordinal}: <No knowledge>"
600 match sem_ref.knowledge_type:
601 case "entity":
602 entity = knowledge
603 assert isinstance(entity, kplib.ConcreteEntity)
604 res = [f"{entity.name} [{', '.join(entity.type)}]"]
605 if entity.facets:
606 for facet in entity.facets:
607 value = facet.value
608 if isinstance(value, kplib.Quantity):
609 value = f"{value.amount} {value.units}"
610 elif isinstance(value, float) and value.is_integer():
611 value = int(value)
612 res.append(f"<{facet.name}:{value}>")
613 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
614 case "action":
615 action = knowledge
616 assert isinstance(action, kplib.Action)
617 res = []
618 res.append("/".join(repr(verb) for verb in action.verbs))
619 if action.verb_tense:
620 res.append(f"[{action.verb_tense}]")
621 if action.subject_entity_name != "none":
622 res.append(f"subj={action.subject_entity_name!r}")
623 if action.object_entity_name != "none":
624 res.append(f"obj={action.object_entity_name!r}")
625 if action.indirect_object_entity_name != "none":
626 res.append(f"ind_obj={action.indirect_object_entity_name}")
627 if action.params:
628 for param in action.params:
629 if isinstance(param, kplib.ActionParam):
630 res.append(f"<{param.name}:{param.value}>")
631 else:
632 res.append(f"<{param}>")
633 if action.subject_entity_facet is not None:
634 res.append(f"subj_facet={action.subject_entity_facet}")
635 return f"{sem_ref.semantic_ref_ordinal}: {' '.join(res)}"
636 case "topic":
637 topic = knowledge
638 assert isinstance(topic, Topic)
639 return f"{sem_ref.semantic_ref_ordinal}: {topic.text!r}]"
640 case "tag":
641 tag = knowledge
642 assert isinstance(tag, str)
643 return f"{sem_ref.semantic_ref_ordinal}: #{tag!r}"
644 case _:
645 return f"{sem_ref.semantic_ref_ordinal}: {sem_ref.knowledge!r}"
646
647
648def compare_results(
649 matches_records: list[RawSearchResultData],
650 results: list[search.ConversationSearchResult],
651) -> bool:
652 if len(results) != len(matches_records):
653 print(f"(Result sizes mismatch, {len(results)} != {len(matches_records)})")
654 return False
655 res = True
656 for result, record in zip(results, matches_records):
657 if not compare_message_ordinals(
658 result.message_matches, record["messageMatches"]
659 ):
660 res = False
661 if not compare_semantic_ref_ordinals(
662 (
663 []
664 if "entity" not in result.knowledge_matches
665 else result.knowledge_matches["entity"].semantic_ref_matches
666 ),
667 record.get("entityMatches", []),
668 "entity",
669 ):
670 res = False
671 if not compare_semantic_ref_ordinals(
672 (
673 []
674 if "action" not in result.knowledge_matches
675 else result.knowledge_matches["action"].semantic_ref_matches
676 ),
677 record.get("actionMatches", []),
678 "action",
679 ):
680 res = False
681 if not compare_semantic_ref_ordinals(
682 (
683 []
684 if "topic" not in result.knowledge_matches
685 else result.knowledge_matches["topic"].semantic_ref_matches
686 ),
687 record.get("topicMatches", []),
688 "topic",
689 ):
690 res = False
691 return res
692
693
694# Special case: In the Podcast, these messages are all Kevin saying "Yeah",
695# so if the difference is limited to these, we consider it a match.
696NOISE_MESSAGES = frozenset({42, 46, 52, 68, 70})
697
698
699def compare_message_ordinals(aa: list[ScoredMessageOrdinal], b: list[int]) -> bool:
700 a = [aai.message_ordinal for aai in aa]
701 if set(a) ^ set(b) <= NOISE_MESSAGES:
702 return True
703 print("Message ordinals do not match:")
704 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
705 return False
706
707
708def compare_semantic_ref_ordinals(
709 aa: list[ScoredSemanticRefOrdinal], b: list[int], label: str
710) -> bool:
711 a = [aai.semantic_ref_ordinal for aai in aa]
712 if sorted(a) == sorted(b):
713 return True
714 print(f"{label.capitalize()} SemanticRef ordinals do not match:")
715 utils.list_diff(" Expected:", b, " Actual:", a, max_items=20)
716 return False
717
718
719def compare_and_print_diff(a: object, b: object) -> bool: # True if equal
720 """Diff two objects whose repr() is a valid Python expression."""
721 if a == b:
722 return True
723 a_repr = repr(a)
724 b_repr = repr(b)
725 if a_repr == b_repr:
726 return True
727 # Shorten floats so slight differences in score etc. don't cause false positives.
728 a_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", a_repr)
729 b_repr = re.sub(r"\b\d\.\d\d+", lambda m: f"{float(m.group()):.3f}", b_repr)
730 if a_repr == b_repr:
731 return True
732 a_formatted = utils.format_code(a_repr)
733 b_formatted = utils.format_code(b_repr)
734 print_diff(a_formatted, b_formatted, n=2)
735 return False
736
737
738async def compare_answers(
739 context: ProcessingContext, expected: tuple[str, bool], actual: tuple[str, bool]
740) -> float:
741 expected_text, expected_success = expected
742 actual_text, actual_success = actual
743
744 if expected_success != actual_success:
745 print(f"Expected success: {expected_success}; actual: {actual_success}")
746 return 0.000
747
748 if not actual_success:
749 print(Fore.GREEN + f"Both failed" + Fore.RESET)
750 return 1.001
751
752 if expected_text == actual_text:
753 print(Fore.GREEN + f"Both equal" + Fore.RESET)
754 return 1.000
755
756 if len(expected_text.splitlines()) <= 100 and len(actual_text.splitlines()) <= 100:
757 n = 100
758 else:
759 n = 2
760 print_diff(expected_text, actual_text, n=n)
761 return await equality_score(context, expected_text, actual_text)
762
763
764def print_diff(a: str, b: str, n: int) -> None:
765 diff = difflib.unified_diff(
766 a.splitlines(),
767 b.splitlines(),
768 fromfile="expected",
769 tofile="actual",
770 n=n,
771 )
772 for x in diff:
773 if x.startswith("-"):
774 print(Fore.RED + x.rstrip("\n") + Fore.RESET)
775 elif x.startswith("+"):
776 print(Fore.GREEN + x.rstrip("\n") + Fore.RESET)
777 else:
778 print(x.rstrip("\n"))
779
780
781async def equality_score(context: ProcessingContext, a: str, b: str) -> float:
782 if a == b:
783 return 1.0
784 if a.lower() == b.lower():
785 return 0.999
786 embeddings = await context.embedding_model.get_embeddings([a, b])
787 assert embeddings.shape[0] == 2, "Expected two embeddings"
788 return np.dot(embeddings[0], embeddings[1])
789
790
791### Run main ###
792
793if __name__ == "__main__":
794 try:
795 main()
796 except (KeyboardInterrupt, BrokenPipeError):
797 print()
798 sys.exit(1)
799