langroid 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
langroid/agent/base.py CHANGED
@@ -784,15 +784,51 @@ class Agent(ABC):
784
784
  # ]
785
785
  # }
786
786
 
787
+ if not isinstance(json_data, dict):
788
+ return None
789
+
787
790
  properties = json_data.get("properties")
788
- if properties is not None:
791
+ if isinstance(properties, dict):
789
792
  json_data = properties
790
793
  request = json_data.get("request")
791
- if (
792
- request is None
793
- or not (isinstance(request, str))
794
- or request not in self.llm_tools_handled
795
- ):
794
+
795
+ if request is None:
796
+ handled = [self.llm_tools_map[r] for r in self.llm_tools_handled]
797
+ default_keys = set(ToolMessage.__fields__.keys())
798
+ request_keys = set(json_data.keys())
799
+
800
+ def maybe_parse(tool: type[ToolMessage]) -> Optional[ToolMessage]:
801
+ all_keys = set(tool.__fields__.keys())
802
+ non_inherited_keys = all_keys.difference(default_keys)
803
+ # If the request has any keys not valid for the tool and
804
+ # does not specify some key specific to the type
805
+ # (e.g. not just `purpose`), the LLM must explicitly specify `request`
806
+ if not (
807
+ request_keys.issubset(all_keys)
808
+ and len(request_keys.intersection(non_inherited_keys)) > 0
809
+ ):
810
+ return None
811
+
812
+ try:
813
+ return tool.parse_obj(json_data)
814
+ except ValidationError:
815
+ return None
816
+
817
+ candidate_tools = list(
818
+ filter(
819
+ lambda t: t is not None,
820
+ map(maybe_parse, handled),
821
+ )
822
+ )
823
+
824
+ # If only one valid candidate exists, we infer
825
+ # "request" to be the only possible value
826
+ if len(candidate_tools) == 1:
827
+ return candidate_tools[0]
828
+ else:
829
+ return None
830
+
831
+ if not isinstance(request, str) or request not in self.llm_tools_handled:
796
832
  return None
797
833
 
798
834
  message_class = self.llm_tools_map.get(request)
@@ -427,11 +427,11 @@ class ChatAgent(Agent):
427
427
  but the Assistant fn-calling seems to pay attn to these,
428
428
  and if we don't want this, we should set this to False.)
429
429
  """
430
+ if require_recipient and message_class is not None:
431
+ message_class = message_class.require_recipient()
430
432
  super().enable_message_handling(message_class) # enables handling only
431
433
  tools = self._get_tool_list(message_class)
432
434
  if message_class is not None:
433
- if require_recipient:
434
- message_class = message_class.require_recipient()
435
435
  request = message_class.default_value("request")
436
436
  llm_function = message_class.llm_function_schema(defaults=include_defaults)
437
437
  self.llm_functions_map[request] = llm_function
@@ -538,12 +538,13 @@ class DocChatAgent(ChatAgent):
538
538
  ]
539
539
 
540
540
  def get_field_values(self, fields: list[str]) -> Dict[str, str]:
541
- """Get string-listing of possible values of each filterable field,
541
+ """Get string-listing of possible values of each field,
542
542
  e.g.
543
543
  {
544
544
  "genre": "crime, drama, mystery, ... (10 more)",
545
545
  "certificate": "R, PG-13, PG, R",
546
546
  }
547
+ The field names may have "metadata." prefix, e.g. "metadata.genre".
547
548
  """
548
549
  field_values: Dict[str, Set[str]] = {}
549
550
  # make empty set for each field
@@ -556,8 +557,11 @@ class DocChatAgent(ChatAgent):
556
557
  for d in docs:
557
558
  # extract fields from d
558
559
  doc_field_vals = extract_fields(d, fields)
559
- for field, val in doc_field_vals.items():
560
- field_values[field].add(val)
560
+ # the `field` returned by extract_fields may contain only the last
561
+ # part of the field name, e.g. "genre" instead of "metadata.genre",
562
+ # so we use the orig_field name to fill in the values
563
+ for (field, val), orig_field in zip(doc_field_vals.items(), fields):
564
+ field_values[orig_field].add(val)
561
565
  # For each field make a string showing list of possible values,
562
566
  # truncate to 20 values, and if there are more, indicate how many
563
567
  # more there are, e.g. Genre: crime, drama, mystery, ... (20 more)
@@ -680,7 +684,13 @@ class DocChatAgent(ChatAgent):
680
684
  )
681
685
  return response
682
686
  if query_str == "":
683
- return None
687
+ return ChatDocument(
688
+ content=NO_ANSWER + " since query was empty",
689
+ metadata=ChatDocMetaData(
690
+ source="No query provided",
691
+ sender=Entity.LLM,
692
+ ),
693
+ )
684
694
  elif query_str == "?" and self.response is not None:
685
695
  return self.justify_response()
686
696
  elif (query_str.startswith(("summar", "?")) and self.response is None) or (
@@ -22,7 +22,6 @@ from langroid.mytypes import DocMetaData, Document
22
22
  from langroid.parsing.table_loader import describe_dataframe
23
23
  from langroid.utils.constants import DONE, NO_ANSWER
24
24
  from langroid.utils.pydantic_utils import (
25
- clean_schema,
26
25
  dataframe_to_documents,
27
26
  )
28
27
  from langroid.vector_store.lancedb import LanceDB
@@ -41,24 +40,26 @@ class LanceDocChatAgent(DocChatAgent):
41
40
  def _get_clean_vecdb_schema(self) -> str:
42
41
  """Get a cleaned schema of the vector-db, to pass to the LLM
43
42
  as part of instructions on how to generate a SQL filter."""
43
+
44
+ tbl_pandas = (
45
+ self.vecdb.client.open_table(self.vecdb.config.collection_name)
46
+ .search()
47
+ .limit(1)
48
+ .to_pandas(flatten=True)
49
+ )
44
50
  if len(self.config.filter_fields) == 0:
45
- filterable_fields = (
46
- self.vecdb.client.open_table(self.vecdb.config.collection_name)
47
- .search()
48
- .limit(1)
49
- .to_pandas(flatten=True)
50
- .columns.tolist()
51
- )
51
+ filterable_fields = tbl_pandas.columns.tolist()
52
52
  # drop id, vector, metadata.id, metadata.window_ids, metadata.is_chunk
53
- for fields in [
54
- "id",
55
- "vector",
56
- "metadata.id",
57
- "metadata.window_ids",
58
- "metadata.is_chunk",
59
- ]:
60
- if fields in filterable_fields:
61
- filterable_fields.remove(fields)
53
+ filterable_fields = list(
54
+ set(filterable_fields)
55
+ - {
56
+ "id",
57
+ "vector",
58
+ "metadata.id",
59
+ "metadata.window_ids",
60
+ "metadata.is_chunk",
61
+ }
62
+ )
62
63
  logger.warning(
63
64
  f"""
64
65
  No filter_fields set in config, so using these fields as filterable fields:
@@ -69,15 +70,7 @@ class LanceDocChatAgent(DocChatAgent):
69
70
 
70
71
  if self.from_dataframe:
71
72
  return self.df_description
72
- schema_dict = clean_schema(
73
- self.vecdb.schema,
74
- excludes=["id", "vector"],
75
- )
76
- # intersect config.filter_fields with schema_dict.keys() in case
77
- # there are extraneous fields in config.filter_fields
78
- filter_fields_set = set(
79
- self.config.filter_fields or schema_dict.keys()
80
- ).intersection(schema_dict.keys())
73
+ filter_fields_set = set(self.config.filter_fields)
81
74
 
82
75
  # remove 'content' from filter_fields_set, even if it's not in filter_fields_set
83
76
  filter_fields_set.discard("content")
@@ -85,10 +78,14 @@ class LanceDocChatAgent(DocChatAgent):
85
78
  # possible values of filterable fields
86
79
  filter_field_values = self.get_field_values(list(filter_fields_set))
87
80
 
81
+ schema_dict: Dict[str, Dict[str, Any]] = dict(
82
+ (field, {}) for field in filter_fields_set
83
+ )
88
84
  # add field values to schema_dict as another field `values` for each field
89
85
  for field, values in filter_field_values.items():
90
- if field in schema_dict:
91
- schema_dict[field]["values"] = values
86
+ schema_dict[field]["values"] = values
87
+ dtype = tbl_pandas[field].dtype.name
88
+ schema_dict[field]["dtype"] = dtype
92
89
  # if self.config.filter_fields is set, restrict to these:
93
90
  if len(self.config.filter_fields) > 0:
94
91
  schema_dict = {
@@ -37,20 +37,30 @@ class QueryPlanCriticConfig(LanceQueryPlanAgentConfig):
37
37
  system_message = f"""
38
38
  You are an expert at carefully planning a query that needs to be answered
39
39
  based on a large collection of documents. These docs have a special `content` field
40
- and additional FILTERABLE fields in the SCHEMA below:
40
+ and additional FILTERABLE fields in the SCHEMA below, along with the
41
+ SAMPLE VALUES for each field, and the DTYPE in PANDAS TERMINOLOGY.
41
42
 
42
43
  {{doc_schema}}
43
44
 
45
+ The ORIGINAL QUERY is handled by a QUERY PLANNER who sends the PLAN to an ASSISTANT,
46
+ who returns an ANSWER.
47
+
44
48
  You will receive a QUERY PLAN consisting of:
45
- - ORIGINAL QUERY,
46
- - SQL-Like FILTER, WHICH CAN BE EMPTY (and it's fine if results sound reasonable)
49
+ - ORIGINAL QUERY from the user, which a QUERY PLANNER processes,
50
+ to create a QUERY PLAN, to be handled by an ASSISTANT.
51
+ - PANDAS-LIKE FILTER, WHICH CAN BE EMPTY (and it's fine if results sound reasonable)
47
52
  FILTER SHOULD ONLY BE USED IF EXPLICITLY REQUIRED BY THE QUERY.
48
- - REPHRASED QUERY that will be used to match against the CONTENT (not filterable)
49
- of the documents.
53
+ - REPHRASED QUERY (CANNOT BE EMPTY) that will be used to match against the
54
+ CONTENT (not filterable) of the documents.
50
55
  In general the REPHRASED QUERY should be relied upon to match the CONTENT
51
56
  of the docs. Thus the REPHRASED QUERY itself acts like a
52
57
  SEMANTIC/LEXICAL/FUZZY FILTER since the Assistant is able to use it to match
53
- the CONTENT of the docs in various ways (semantic, lexical, fuzzy, etc.).
58
+ the CONTENT of the docs in various ways (semantic, lexical, fuzzy, etc.).
59
+ Keep in mind that the ASSISTANT does NOT know anything about the FILTER fields,
60
+ so the REPHRASED QUERY should NOT mention ANY FILTER fields.
61
+ The assistant will answer based on documents whose CONTENTS match the QUERY,
62
+ possibly REPHRASED.
63
+ !!!!****THE REPHRASED QUERY SHOULD NEVER BE EMPTY****!!!
54
64
  - DATAFRAME CALCULATION, which must be a SINGLE LINE calculation (or empty),
55
65
  [NOTE ==> This calculation is applied AFTER the FILTER and REPHRASED QUERY.],
56
66
  - ANSWER received from an assistant that used this QUERY PLAN.
@@ -43,23 +43,27 @@ class LanceQueryPlanAgentConfig(ChatAgentConfig):
43
43
  You will receive a QUERY, to be answered based on an EXTREMELY LARGE collection
44
44
  of documents you DO NOT have access to, but your ASSISTANT does.
45
45
  You only know that these documents have a special `content` field
46
- and additional FILTERABLE fields in the SCHEMA below:
46
+ and additional FILTERABLE fields in the SCHEMA below, along with the
47
+ SAMPLE VALUES for each field, and the DTYPE in PANDAS TERMINOLOGY.
47
48
 
48
49
  {{doc_schema}}
49
50
 
50
51
  Based on the QUERY and the above SCHEMA, your task is to determine a QUERY PLAN,
51
52
  consisting of:
52
- - a FILTER (can be empty string) that would help the ASSISTANT to answer the query.
53
+ - a PANDAS-TYPE FILTER (can be empty string) that would help the ASSISTANT to
54
+ answer the query.
53
55
  Remember the FILTER can refer to ANY fields in the above SCHEMA
54
56
  EXCEPT the `content` field of the documents.
55
57
  ONLY USE A FILTER IF EXPLICITLY MENTIONED IN THE QUERY.
56
58
  TO get good results, for STRING MATCHES, consider using LIKE instead of =, e.g.
57
59
  "CEO LIKE '%Jobs%'" instead of "CEO = 'Steve Jobs'"
58
- - a possibly REPHRASED QUERY to be answerable given the FILTER.
60
+ YOUR FILTER MUST BE A PANDAS-TYPE FILTER, respecting the shown DTYPES.
61
+ - a possibly REPHRASED QUERY (CANNOT BE EMPTY) to be answerable given the FILTER.
59
62
  Keep in mind that the ASSISTANT does NOT know anything about the FILTER fields,
60
63
  so the REPHRASED QUERY should NOT mention ANY FILTER fields.
61
64
  The assistant will answer based on documents whose CONTENTS match the QUERY,
62
65
  possibly REPHRASED.
66
+ !!!!****THE REPHRASED QUERY SHOULD NEVER BE EMPTY****!!!
63
67
  - an OPTIONAL SINGLE-LINE Pandas-dataframe calculation/aggregation string
64
68
  that can be used to calculate the answer to the original query,
65
69
  e.g. "df["rating"].mean()",
@@ -99,7 +103,7 @@ class LanceQueryPlanAgentConfig(ChatAgentConfig):
99
103
  hence this computation will give the total deaths in shoplifting crimes.
100
104
  ------------- END OF EXAMPLE ----------------
101
105
 
102
- The FILTER must be a SQL-like condition, e.g.
106
+ The FILTER must be a PANDAS-like condition, e.g.
103
107
  "year > 2000 AND genre = 'ScienceFiction'".
104
108
  To ensure you get useful results, you should make your FILTER
105
109
  NOT TOO STRICT, e.g. look for approximate match using LIKE, etc.
@@ -1,16 +1,21 @@
1
1
  import logging
2
2
 
3
3
  from langroid.agent.tool_message import ToolMessage
4
- from langroid.pydantic_v1 import BaseModel
4
+ from langroid.pydantic_v1 import BaseModel, Field
5
5
 
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
 
9
9
  class QueryPlan(BaseModel):
10
- original_query: str
11
- query: str
12
- filter: str
13
- dataframe_calc: str = ""
10
+ original_query: str = Field(..., description="The original query for reference")
11
+ query: str = Field(..., description="A possibly NON-EMPTY rephrased query")
12
+ filter: str = Field(
13
+ "",
14
+ description="Filter condition if needed (or empty if no filter is needed)",
15
+ )
16
+ dataframe_calc: str = Field(
17
+ "", description="An optional Pandas-dataframe calculation/aggregation string"
18
+ )
14
19
 
15
20
 
16
21
  class QueryPlanTool(ToolMessage):
@@ -19,8 +24,9 @@ class QueryPlanTool(ToolMessage):
19
24
  Given a user's query, generate a query <plan> consisting of:
20
25
  - <original_query> - the original query for reference
21
26
  - <filter> condition if needed (or empty string if no filter is needed)
22
- - <query> - a possibly rephrased query that can be used to match the CONTENT
23
- of the documents (can be same as <original_query> if no rephrasing is needed)
27
+ - <query> - a possibly NON-EMPTY rephrased query that can be used to match the
28
+ CONTENT of the documents
29
+ (can be same as <original_query> if no rephrasing is needed)
24
30
  - <dataframe_calc> - a Pandas-dataframe calculation/aggregation string
25
31
  that can be used to calculate the answer
26
32
  (or empty string if no calculation is needed).
@@ -34,7 +40,7 @@ class QueryPlanAnswerTool(ToolMessage):
34
40
  Assemble query <plan> and <answer>
35
41
  """
36
42
  plan: QueryPlan
37
- answer: str
43
+ answer: str = Field(..., description="The answer received from the assistant")
38
44
 
39
45
 
40
46
  class QueryPlanFeedbackTool(ToolMessage):