microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
d9c8f9c6f1f69402e35fe5994c52ca8463307043

Branches

Tags

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

Clone

HTTPS

Download ZIP

python/ta/tools/utool.py

917lines · modecode

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