diff --git a/core-api/core_api/build_chains.py b/core-api/core_api/build_chains.py index f12b19af3..3e4ca77a5 100644 --- a/core-api/core_api/build_chains.py +++ b/core-api/core_api/build_chains.py @@ -58,7 +58,7 @@ def make_document_context(): @chain def map_operation(input_dict): system_map_prompt = env.ai.map_system_prompt - prompt_template = PromptTemplate.from_template(env.ai.map_question_prompt) + prompt_template = PromptTemplate.from_template(env.ai.chat_map_question_prompt) formatted_map_question_prompt = prompt_template.format(question=input_dict["question"]) @@ -222,14 +222,14 @@ def make_document_context(): @chain def map_operation(input_dict): system_map_prompt = env.ai.map_system_prompt - prompt_template = PromptTemplate.from_template(env.ai.map_question_prompt) + prompt_template = PromptTemplate.from_template(env.ai.chat_map_question_prompt) formatted_map_question_prompt = prompt_template.format(question=input_dict["question"]) map_prompt = ChatPromptTemplate.from_messages( [ ("system", system_map_prompt), - ("human", formatted_map_question_prompt + env.ai.map_document_prompt), + ("human", formatted_map_question_prompt + env.ai.chat_map_question_prompt), ] ) diff --git a/notebooks/langgraph.ipynb b/notebooks/langgraph.ipynb new file mode 100644 index 000000000..571f68a73 --- /dev/null +++ b/notebooks/langgraph.ipynb @@ -0,0 +1,118 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "from redbox.graph.root import get_redbox_graph, run_redbox\n", + "from redbox.graph.chat import get_chat_with_docs_graph\n", + "from redbox.chains import components\n", + "from redbox.models.settings import Settings\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "app = get_redbox_graph()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAEvAYcDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBQgCAwQBCf/EAFwQAAEDBAADAwQLCA4IAwgDAAEAAgMEBQYRBxIhExQxCBYiURUXMkFWYXGBk5TSI1NVkpXR09QJJTM2OFRicnR1kbKztBgkNTdCUnehNHaiQ0Zzg4SWpLHBwtX/xAAbAQEBAAMBAQEAAAAAAAAAAAAAAQIDBAUGB//EADQRAQABAgEKBAUEAgMAAAAAAAABAhEDBBIUITFRUmGR0RNBobEFFTNxwSNTgeEiMkJD8P/aAAwDAQACEQMRAD8A/VNERAREQEREBERAREQEREBERAREQEREBERAREQEREBEUdq62tyKtqKC1zuoaKneYqq5NYC9z9dY4N7Gxv0nkENPogF3MY86KJq+yxF2bqq6moWB9TURU7D/AMUrw0f914fOqyfhig+tM/OvJS4Dj9M8yOtdPWVJ0XVVc3vEziPAl8m3ev3/AH17PNay/gig+rM/MttsGPOZ6f2anzzqsn4YoPrTPzp51WT8MUH1pn51981rL+CKD6sz8yea1l/BFB9WZ+ZP0efoup886rJ+GKD60z86edVk/DFB9aZ+dffNay/gig+rM/MnmtZfwRQfVmfmT9Hn6Gp886rJ+GKD60z86edVk/DFB9aZ+dffNay/gig+rM/MnmtZfwRQfVmfmT9Hn6Gp7aSvpq9hdTVEVQ0eLonhwH9i71H6vAMdrJBKbPSwVIO21VIzu87T/Jlj5Xj3vA+8uqCqrcVqIaa41MtytkzxHDcJWtEkDz0ayYgAEE6DX6HUgO2TzFmUVfTnXun8f+hLbklREXOgiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiDC5ld5rFi1zrqbl71HCRT8/ue1d6Me/i5i3a9ljtENhtFJb6fZip4wwOcdueffcSfEk7JJ6kkrE8RaeSfCro6JjpJKdjaoMaNud2TmyaA98nk0FIIJmVMMc0Tg+ORoe1w8CCNgronVgxbfPtFveV8nYiiuTcWMIwu4i35DmWP2GvMYlFLc7pBTylh2A7le4HR0euveKxJ8oThYNb4l4eN+H7fUv6Rc6OXEnjFbuG93sdndZr1kV7vLZ5KS2WOmZNMYoQ0yyHnexoa3nZ/xbJPQFRGs47XyHj7acNgw28VVlrcfjubpY4IGTwySTxs7STtJ2lsUbXFr2hpfzb0HALE8aay28aLFQuwayUnEmpoTP2F9xfJqalq7FVljeyeyUSAgO2S4B3gwbY8HogxXiRiPEDA8uqbMzOLiMRbjt+dRVsNM+Kr7WKV1SO1LA9hc14Ib18CG+8gmeQcfLdimXxWa74xk9vt8tfDbGZHNb2i2GeUhsbe05+flc5zWh/Jy7OtrjScfKK75pkOMWfFMlvNdYKzudwnpKeAU8TjC2Vju0kmaHBwdygD0gQdtAIcaG4i8DM4yCvymabAWZPkhyaO8W7K6q7wDlt0VVHNFR00b3c0TxGzsy0hjCeZxed9b94Q4dd8XyzilW3Oj7rT3vJO/wBA/tGP7aDudNHzaaSW+nG8ado9N60QUGP8mjjDe+MuAx3e+Y3W2Wq7Sb/W3shZSVIFRMwNhDZpH7Y2NrXc4b6XhzDqrdVB8FrrWcBsKGLcQ4LdilnttZVx0OTXC80sdJcjLUyzRtY1zw9j+RxJa4D3B1tT5vlA8LnteW8ScQcGDmcRfaU8o2Bs/dPWQPnQT5eW522nvFuqaGrj7WmqY3RSM3rbSNHr73yqO43xbwbMrmLdYM0x6+XBzC8UltusFRKWjxPIx5Oh750pWSACSdAe+VYmYm8DBYPcp7pjFHJVvEtZCZKSokHg+WGR0UjvndG4/Os8ozw6YTisVSQ4Nrqmqr2BzeU8k9RJKzY/mvapMt2PERi1xG+fdZ2iIi0IIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAopb6iPBAy2VhbBYweWgqzvkp2+9BKfBgHgxx0CNNOnAc8rXGSNk0bo5GtfG8FrmuGwQfEELZRXm3pq1xKxLrlpKeodzyQxSO17pzASuHsbSfxWD6MfmWB9r63U5/a2puFlZsfcbfVvZCNeAbEdsaPia0f8AYLj5kT/Cm/fTxfolszMOdlfWO1y0b0lhp4qcERRsjB6kMaBtdii3mRP8Kb99PF+iTzIn+FN++ni/RJ4eHx+kraN6Uotfa+85BTeVLa+HzMnuvsDU4pLeXuL4+37dtT2Q07k1y8vva8ffVs+ZE/wpv308X6JPDw+P0ktG9JZoI6hobLGyRoO9PaCF1extJ/FYPox+ZR/zIn+FN++ni/RJ5kT/AApv308X6JPDw+P0ktG9I4qOngfzRwRxu/5msAKjVzrm5qJrRbJWy2124rhXxO9AN6h0Mbh7p59y4g+gN/8AFoLsPD+hqj+2Nbc7vHvfY1la8wn+dG3la4fE4EKRwQRUsEcMMbIYY2hrI42hrWgeAAHgEiaMPXTN59I7+n8mqHKONkMbWMaGMaA1rWjQAHgAFyRFzsRERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREGu92/h92H/p9Uf54LYha73b+H3Yf+n1R/ngtiEBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREGu92/h92H/AKfVH+eC2IWu92/h92H/AKfVH+eC2IQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQERRu9ZRVRXGS3WijirauFrXVEtTMYoYebq1uw1xc8jrygdBrZG272UYdWJNqVtdJEUI9nMw/iNj+tTfo09nMw/iNj+tTfo10aLXvjrBZN1EuLPDi3cXeG+QYfdOlHdqV0Haa2YpAQ6OQD3yx7WOHxtXl9nMw/iNj+tTfo09nMw/iNj+tTfo00WvfHWCz8O7rw4v9o4iz4PNb5HZJFcfYvubBtz5+fkDW+GwTrR8CCD76/cHgZwvp+DHCXGcMppe3FqpeSWYb1JM9xkmeN9QDI95A94EBVBXeTzLX+UbScYX0NnF5go+xNEJ5exkqA3s21Lj2e+ZsZ5deHotPQjrcXs5mH8Rsf1qb9Gmi1746wWTdFCPZzMP4jY/rU36NPZzMP4jY/rU36NNFr3x1gsm6KEezmYfxGx/Wpv0a99nyqt9kIKC90UFHNUktpp6Sd0sMrgCSw8zWljtAkDqCAeu+ixqybEpi+qf5gslCIi5UEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQFAbId5LmW/H2VYN/wD0VKp8oDY/3yZn/WzP8lSruyXZX9vzCx5s2iofivm2VYrxZt7bhkdThuASU9M2nulPaYqylnq3TESQ1krgXU4LezDHDlbtx27Y0ofeOKvFXNchzWowujvYprBdamz0FFQ262zUNVNT6Du9S1FQycc79/uQbytLSOc7WU1WRtN2jO07Pmbz65uXfXXr0uS10xaz5BdvKsvFyqL/AF9mecWtFXV2eOGlkjAMtQHUrnmMu5Wva93M1wcS8+loNA2LVibgi1mtvE3N7VgHEziLdcjfcaLGLpe6W32CKhgZDPHBNJHD28gZ2h5Ty9WOb6LPS5iSVmBk+fcNstw635DlseVQ5ZRVzXM9joab2Pq4KU1DXQmMAviIa9upOY+5PN1IUzhsCuLJGSFwa5ri08rgDvR9R/tCoC2cU8oqOFPAO8SXPmuWUXS3U13m7vEO8xy0c8kjeXl0zb2NO2BpGtDQ2oVgWRXvgxwt4zZtJkFbkItWQ3iKK11lPTRwSVXemtbUPdHG1+y4jmaHBgBOmjppnDbZYLJjqtxsjx9l4evzPCqDhhfOLbM9tNNfqG/XDHayGYXGpvlBa6RtFIGc0boO6VD3ua5wLC14cRzA83Qq38n/APGY3/W8H/6ct+DN6uvssbVgIiLyEEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEWLOU2YX8WI3egF7MRmFt7yzvJjGtv7PfNy9R11rqoJS+UViN+w7KMixb2TzOHHajulZQ2Kgkkq3zbaDHFG8M7QjmB2DroevRBZ6Ktbnnub3GjwWvxfA5KihvL2S3ll7q20NVZ4SYyQ6E7L5NOk9EHoWDx2snbrbnx4oXmruF3s/mC6kEVtt1LTvFc2ciMullkd6PQiUAN6ac3fUIJusXcspstmuNDb7hd6Chr693JSUtTUsjlqHeqNriC8/EAVXVDwBF24W3HCs7y29Z3DcKsVU1wqpe6zgAxkRMMRHKzcZ6b/43BS6PhViLZcamlx+hrKrG6dtNaKmtiFRNRsaGgcj37cHaY30t83TxQY+28a8UvmW5Ti1prJ7pkeNQdvcbbT0kokZ02GMLmta9x6aDXHxHVRvhLlk2cU2SXuexXTGpKu6k+xd6hENXCG01O0dowE8pcGh2tno4K3Wsawu5WhvMdnQ1s+tQm5U1VjV+uVYKKorrdc5WVDn0cRlkglEbIi1zG+kWlsbSHDfXmB16O+3JZi9VPnMfmFhDuIvBC3cT7g593yHIY7RNHFFV2GlrWsoKtsb+cB7CwuGz4ljmlwAB8F5rnwAtVTlVzvlryLJcYN2mbU3OgsdwEFNWzAAdo5pY5zHuDQHOjcwu112stc+NGLWW/W6x3Ceuorzctmit9RbqhlRU68ezjLOZ2viCzXnnT/gq/fkSr/Rrq8Cvhlc2dzAZXwnprzm9Lmlvul2tOQUtI2kfHb6tkMFwiZIZY4agOjf6HOXek0B2nu6nwXUMi4pbG8GxkD3yMrm//wA9STzzp/wVfvyJV/o0886f8FX78iVf6NPAxOGUzZYyx8KLDZcVyHHXsmuVpv1ZXVldBWua7nNW9z5mDlDdM28ge+B75PVYTDOANmxG/UN3qL1fsnqbbSPobY2/VjahlvheAHtiDWN6ua1rS9/M4ga3pS7zzp/wVfvyJV/o0886f8FX78iVf6NPAr4VzZ3K+tPkyWGz1GMCPIclnt2M3BtfZ7VPWxupaQhr2iIN7LmcwCQgc7nOaAA1wBIOVj4A4+255U99ddZ7Jkzp5LljktQ11vklmaBLK1vJzte7W+j9A9QAVLPPOn/BV+/IlX+jTzzp/wAFX78iVf6NPAr4TNncwPDzhGzh5Vtljy3KL9BFTd0pqO9XBs0FPHtpAa1rG8zhygBzy5wGxvqVIMn/APGY3/W8H/6csHR8aMWuOTVmOUs9dUZBRRCeptcVuqHVMMZ1p74wzmDfSb1I16Q9a7MrxSv4u2j2MpKy94fTRyCcXmBndqtsjQeQRNeOYdSC5xA6eiN8xLcqaJwv8qotEX9iImNcrYRV7W0PEWx3LCKGy1VmvVgpomU2Q1t5MrbjPytaO3h5PufMeVxcHe+/p4Lst3FqOS/5jQXjHL3jdtxqE1Ut+udO1lvqoAHF0kMgcS7QY4ka6ADfjpeMxT5FhsQzKx5/j9NfMcutLebRU83ZVlJIHxu0SHDfrBBBB6ghZlAREQEREBERAREQEREBERAREQEREBERARRvIuJGL4njdyyC636hpLNbZBDWVpmDo4JCWgMeW704l7Brx9IetYG8cbbLba/B4KO3XrIKbMAx9vuFmoHVFNHE7s9TTv2Ozj1Kx2yPDZ94oLCRQihyjMrhm+RWiTDG2uw0VNzW3I6i4xSx185DSGd3b90Y0cx24+PKdKPuxPirmHC11tvmYWrDczlqhI+6YpROqII4AR9ya2oO+YjYLve6EepBa6w1bmVit9NeJ57tSBlngdU3BrJQ99LG1rnFz2N24dGu97rylR648JKC+ZXi2SXO7XipumPwiOFkVa+Glnk0Q6WSBp5XOOz4+vXvBZOw8MsUxi73662uwUNHcr7IZbnVMiBkq3Eknncdkjbj08Op6IIjePKPxmHhnbs5xuhvee2i5VhoKSHGbc+oqJZQZA49m7kcGgxOBcfi1sEKQXDKsuh4oWmx0eF97w+opDPW5S65Rx91l1Jyw92I53kljNuB0BIN+BUxp6eKkhZDBEyGJg02ONoa1o9QA8F2IK0ttg4n3W3ZzRX7JbLan1r5Iscr7DRvfLQREvDZJmzdHyaMZIB1sO0fArouPAWlyrDMWseV5Nfr7VWOo70bnHWGllrZASR2wj6OaNjTf5IVpIgj7eH2NNzN+XewVAcndAKY3YwNNSIh05A89QNdOizdPTQ0kQigiZDENkMjaGgb6noF2ogIiICLhJKyFodI9rGlwaC46GydAfKSQPnVcycRLjm94zzEMYt90sV7stMIqfIbtbT7HOqnsJaIyT905QY3Hpoh2+o8Qmd7y2yY1VW2mu12orbU3OobS0UNVO2N9TMfCONpO3O6+AUEkqsw4ox8QMaq7Tc+HdticKG0ZNSVsT6qr6u5542AbjboM5SepDj1aR0yuP8ACqlltmJVGbuos4y/Ho39jkNZQRxyCV5BdJGwbEZ9Fnh19EFTxBg8VxKlxSxWa2tnqbpJaqQUUNxubxNVvYA0Evk0CS7kaXHpstG/BZxEQEREBERAREQYu/Y7S3+3XGme6SjmrqOSifXUZEdTHG4EehJrbSNkj1Hqq6iZmHBnHcIx+2W68cUaZ1aaO53uvr4mVtJA9/3OZ4IHahvMASOvKzZKtlEGJtWW2S+3W6Wy3Xeirrla5GxV1JTztfLTPLQ4CRoO27BHj8fqKykkbZWOY9oexwIc1w2CPUVDbzwvtp86LnjMdJimYX6j7rLkdLRMfO1wDuSRzToPLS7fU9dDZ6DWCjz+88NIeH+OZjTXPK73enmhqsislrIooajbeR0wB+5NfzHqBr0HEho8AyeecDcQ4hYrQY7W2+S22ygrO/0cdlnfQ93n9PcjeyLRs9pJvYIJeT49V7J8by88UKe9RZcxuHdzMM2Mut0ezMN8szajfP4nq3w9EfGpfHKyXm5HtfyuLTyneiPEH41zQVPQ8WMqxTBMpyPiNhU1nZZqvkghsE4uUtdTFzQ2ZkbdFuuf0gTsBjj0AUwtXE3GLtQYzVNvNLRnJaZtXaaavkFNUVbC1jvQifpxIEjNgDY5h61KFgcgwLG8rulpud4sVvudytEwqLfWVNO181JICDzRvI23q1p6HroepBnkUCo+GFTYMoy7JLVlF7luF8pyIrbdKsz22inDQGSxQaHL7luxs7AWEqst4k8N+GNurb/jcXEXKG1vYVsGI/6uBTHn5Z2smPpOAawOYNek/p0G0FsIohNxaxSl4kUmAT3ZkGX1dEK+G2PjfzPh9PZDwOTY7N5LebehvWiFLIZ46hnPFI2Vmy3mY4EbB0R8xBHzIOaIiAiIgIiICxOV5ZaMHsFXfL9XxWy00gaZ6uckMjDnBo3r1ucB86yyrfyjLljdn4LZPWZdYanJsciiiNZaqTfa1DTMwNDdOb4OLXeI8EGSfxZtY4o02CR2671Fymo+/GvhoXOoIo9Et55/AF3KQB169FiLTmvEPK8ayx1Lg0GK32indBZRf68TU1eAddq/sRzxs6eHj1HxqxLY6N9tpHQxmKExMLIz4tboaHzBelBWtfivEnIrPhjpc1ocVutFI2bIIbNbW1VNcdFpMMTp9PiZ0PpD0tO18ay1Fwto6LilX50L1fZq6rpBRexk1eXW6FmmbMcGtNcTG0k78S4++VNEQQ/D+EGF4DjlRYLBjVvt9lqZ+9TULYueKWX0fTcHb2fQZ1P/ACj1KXMY2NjWMaGtaNBoGgB6lyRAREQEREBERAREQEREBV7JxhtN/wAvyrBcWqo6/OLJbnVUkFRBKKSGVwHZRyyga2S5hLWnfKT6jqwlC8Luea1uZZpT5JZ6K347S1MDMfrKZ4MtZCY9yulAkcQWv6DbWdPePigwVu4TVGe41h8/FmO333KrHVuuLfYl80NCyo5iYyIy4dpyDlALwerd6GyrRREBERAREQEREBERAREQEREBERBWtdwlbiNHnN24bR0dizLJCKmSouD5pqJ9UCT2rouYhpcHO2WjqdEg60frOMVFiF0wbFM8qae25tklN6MdBBM6hfVNDQ+KOUg6Jc48ocdkN6+I3ZKhfEK55rb7riDMSs9FdaGou0cV9lq3hrqShLTzyx7kZt4OhoB/j7koJoiIgIiIOqSmhlmimfEx8sW+zkc0FzNjR0fe2q3i4C2PGsYy63YLUVWD3HJJ+91N1oZXzSx1BOzK1sriAT12BodT76s1EFZ18PE7E6XArZZnWvM4Y3Mpslu93eaSqczcYNRDGz0N67VxaSfBoG9krIUHF2hqeIORYrWWW9WZtmp21Tr5c6TsLZUxkM32M7nacQ55aeg6sd1OlPFXvlBeavtM5Z57d681O5/th3LfbdlzN9zrrvekFgRyMmjbJG4PY4BzXNOwQfAgosbi3c/Nm0ex3P7H9zh7t2nuuy5Byb+PWkQZREXCSVkQBe9rAf8AmOkHNaKeUj+yIZPwgzTKcGpeHtPQ3qgmDKK71dzM0MsRIdHMYBC3fPGR6Ik9EnWzy9d5e+Qff4/xwtG/2TvglFlWHW3iRaWNludk1RXFsWi6Ske70H9Ov3OR2vklJPRqtpFk+SV5Z1w8p/JrvazgbrBRWqgbUVF1bdO8sMzntayLk7FnKXjtXA8x/cyPjW0K1+8ingtBwL4H2yjrmxQ5Hd9XK6czhzskeByQn1dmzlaR4c3OR4q+++Qff4/xwlpHci4se2Roc1wc0+BB2FyUBERAREQERcZJGRDb3NYPDbjpByRdPfIPv8f44TvkH3+P8cK2kdyLp75B9/j/ABwnfIPv8f44S0juRdPfIPv8f44TvkH3+P8AHCWkVR5UHHer8nPhqzL6bF35VC2vipKmnbWd1FPG9r9TOf2b+nO1jNa8ZB1946O4F+yX3yzZ3l1wnw2vyKDIqynfbrNLkEhZbQ1nIYoQYHA87jzei1vX3j4r9Gs/xSy8ScJveL3h8cltu1LJSzacOZocNBzfU5p04H3iAV+b3kW+SnW03lOXx+VQR+x/D+rJLpBqOqq9nuzmb8W6HbAg9NR790lpH6cWOqra6yW+puVE223GanjkqaJk3bNp5S0F8Yk0OcNJI5tDet6C9y6e+Qff4/xwnfIPv8f44S0juRdPfIPv8f44TvkH3+P8cJaR3IunvkH3+P8AHCd8g+/x/jhLSO5F098g+/x/jhdvilrD6iIoCIiAiIgIuEk0cRHPI1m/DmIC4d8g+/x/jhW0juX5i8Vv2SXIbtlGOQR4XcMRqMavgqbpb4MheO/Nj5mPpJg2Bum83jzBw233K/TTvkH3+P8AHC/M3y//ACZq2r4649fsTpm1DM5qWUUscfRkVwGm8ziOjWvZp+/WyVxS0jcjyVPKMuflLYpdciqMMOJ2umqW0lJI6497NW8N3KQOxj5Q3bBvrsucOnKd3goXwkwKzcIeG+P4hapYe6WqlbCZAQ0zSeMkpHre8ucfjcpd3yD7/H+OEtI7kXT3yD7/AB/jhdyWsCIigKIcW7hdbVw3v9XZMeiyu6xU/NT2WdvMyrdzD0CPk2fmUvUQ4t2+63Xhvf6SyZDFil1lp+WnvU7uVlI7mHpk/JsfOgkNilmnslvkqaUUNQ+njdJStGhC4tG2D5D0+ZEsUU0Fkt8dTVCuqGU8bZKpp2JnBo28fKevzog7LpW+xtsq6vl5uwhfLy+vlaTr/sq9teJ2q/W6kuV5t9JeLlVQsmmqa2BkztuaCWt5h6LB4Bo0ND17KnGVfvYvH9Dm/uFR7Gf3uWr+iRf3AvSyeZow5qpm03ZbIeL2vsW+Ddo+oRfZT2vsW+Ddo+oRfZWCg46YPVZg7F6e996vDanuTm09JPJAyoHjE6drDE1499pfsepQ3hH5TuP5lSUFvv8Ac6SgymsudbbmUtPTTtpy+Opmjhj7VwcwSujjY7kL+Y82wNEBbdIxOOeqXnes/wBr7Fvg3aPqEX2U9r7Fvg3aPqEX2Vganjtg1FmrcTqL53e9uqW0TY5aSdsJqHDbYhOWdlznY03n2djosXiHHq15VxbyvBRRV1PVWaojpoKg0NUY6h3Y9pKXvMQjiAO2t5nenoOaSHBPHxOOeped6WT26jwurt1fZ6aG2CWtp6SpgpmCOKdksjYhzMaNFzS5pa7Wxy63ylwNkKvc0/2fbf64tv8AnYVYS58p/wAqaa526/x3WdcCIi4GIiIg+OdyNLj4AbVYWGx27L7LQ3u9UNLdq6407Kl0lXEJhG14DxHGHD0WNHKAABvWztxJNmzfuMn80qvuHX+77GP6rpf8Jq9HJpmnDqqp1TePyyjVDl7X2LfBu0fUIvsp7X2LfBu0fUIvsrBV3HTB7fmBxeS99pem1EdJJDTUk80cMzyAyOSVjDHG8kj0XOB6hQ7hr5TuP5FVz2jI7nSW7IDf66zU8EFNOICY6mSOBj5SHRtlexjTyl4LiejdEBbtIxOOeqXnes72vsW+Ddo+oRfZT2vsW+Ddo+oRfZWBunHbBrJmTcVr753W9GojpOSWknEImkAMcZn5OyD3BzdNL9nY9axds49Wuu41X3h7JRV0VRb4aUw1bKGpkZNLKJS9rnCLkja0Rt09zuV5cQDtpCePicc9S870y9r7Fvg3aPqEX2U9r7Fvg3aPqEX2VjLzmVbFxRx3FLdFTyxz0VVc7pJK1xdDTs5Y4gwggBz5ZB4g+jFJ03oiBcaPKfx7ALbdqCx3OluGW0NXS0hpJaWeWmY+SeNj43ysAY2QRve4MLw7YHQ+CTj4kf8AOeped60Pa+xb4N2j6hF9lPa+xb4N2j6hF9lR/KuPOB4TkMlkvOQxUdwhEZqGiCWSKkEnuO3lYwsh5uhHaObsHfgu3NeN+E8PbrHbL5ehBcXw94NLTUs1VJHFvQkkELHmNm9+k/Q6Hr0Tx8TjnqXnezftfYt8G7R9Qi+yntfYt8G7R9Qi+yuFJxCx+urL/SwXDnnsMMVRcWdjIOwZJEZWHZbp22AnTdkeB0eijFy8ozh9aoKCWe+yO7/aoL5TR09uqppZKGXm5J+zZEXBvoO5tgcnTm5djd8fE456l53pV7X2LfBu0fUIvsp7X2LfBu0fUIvsrAYrx5wTNr3Q2qy39lbV18T5qIimmZDVtYNvEMrmCORzR7prXEt0dgaK4wce8Cqcpbj0eQxOuT6o0LHdhMKZ9QCQYW1BZ2TpNgjkDydjWt9FPHxOOeped6Q+19i3wbtH1CL7Ke19i3wbtH1CL7KjJ8obh8L260nIAKxlxdaZT3Oo7GGrbIYuxkm7Ps43F400OcObYLdggnw2Hj9abvxgyjBJaSspZrR2DYqw0VSYpnOjkklL5OyEcTWhgDXOfp/XlJ8E0jE456l53pp7X+Lj/wB27R9Qi+yu2xww4rlVvttujbS2y4QzF1FEOWKOSMNIcxoGm7BcCBoHodb2Vh8C4wYlxOnqYsaujrkadgkc/uk8LHsJID43yMa2RpII5mEjp4rM1H7/ALGP5lX/AHGrKMSrEiqmqbxafSJWJmdqeIiLxmIiIgLB5td57FitxraYtbUxxhsTnDYa9zg0HXxFwPzLOKKcUv3i3L5Yv8Vi34FMVY1FM7JmPdY2ww7cAx5+31Vno7jUu6yVVdTsmmld77nPcCSf+3q0F99r7Fvg3aPqEX2VmK+vprVQ1NbWTx01JTRummnlcGsjY0Euc4nwAAJJUKx3jpg+U2G7XugvrW2m1RMqKurrKaakZHE8OLJAZWN5mu5Xac3YOui9Hx8TinqXnezvtfYt8G7R9Qi+yntfYt8G7R9Qi+ysFjvHPCMqprtPQXvpaqU11ZFV0k9LNFTgEmbs5WNe5nQ+k0EfGuvHePWC5Vbr7W26+F8VkpTXXCOooqinmhpw1zjL2UkbXubprtFrSDrQ6qePicc9S870h9r7Fvg3aPqEX2U9r7Fvg3aPqEX2VHKLj9gVfjlwyCO/COx0LIny3KopJ4ad4kJDBE97AJnEtLeWMuIPQgE6UWzjyl7Ja8YsmQWGsiktcmR0dnukt1oamndTQyAukcGSCN4cGgEEgjr4FNIxOOeped6zW4BjDHBzcctAcDsEUMWx/wClejGGsx3LI7JRAQ2yroZaqOjBPJTvifEw9mPBjXCZu2jptuwAXOJx2C8Ssb4lUlZUY7ce+topu71MUkEtPNBJoENfFK1r27BBGx1HgshT/wC8+z/1PX/49Grn1YlNVNU3i0+kXWJmdqdIiLx2Iq98oLzV9pnLPPbvXmp3P9sO5b7bsuZvuddd70rCUQ4t3C62rhvf6uyY9Fld1ip+anss7eZlW7mHoEfJs/MgzeLdz82bR7Hc/sf3OHu3ae67LkHJv49aRd1ilmnslvkqaUUNQ+njdJStGhC4tG2D5D0+ZEHTlX72Lx/Q5v7hUexn97lq/okX9wKSZHC+ox66RRtLpJKWVrWj3yWEBRrF3tkxm0OaeZrqOEg+scgXo4P0Z+/4ZeSkPJ7vly4YYvZ+G96w3JReqGsnp5brTW10luqWvne8VfeQeTlcHBzgTzgkjlUaosLv0fk443bzYri260+dsrnUvc5BPHEL8+TtizXMG9kefm1rlO96W06KZvkxabcVaDMMklyQXa0Z3db9QZRBV0FLboJvYeK1QVcUkcjGsIjnkMTSSPTl5z0aAOlxYlJX4l5RGfw11iu76LKX2+qt91pqJ8tGBDRiKRssrekTg6PoHa3zDXirnRM22sYDNP8AZ9t/ri2/52FWEq/zBhlpLXG0+m6728gaPXlqonn/ALNJ+ZWAplH06PvP4XyERFwIIiIOE37jJ/NKr7h1/u+xj+q6X/CarBlBdG8DxIIVf8PW8mB44zeyy3U7HdCNERtBHXr4gr0MD6VX3j2ll5Kd4PXy5cJHXPCrzhuS1dynyGsqo7zbra6oo62KpqXSNqZJweVhax4D2vIcBH0B6BRqtwy/Hyccjt7LFcTdX50+uhpRRydu6L2eZIJms1zFvZbfzAa5dneltOiZvkxaccbLbmOVx55R3K0Z1dr1BeYZrLRWmKVtmbbYZoZWyegRHPKWtkJa7nk5+UNaNAq3KWrrsO8pC/19TYL1WWnK7XaqejuNBQSTQQSwvqGvbUOA+46EzHbfoa3740rrRM3XcVzw0tVbX5nneW3OjqKOaurm2qgiqonMeKGkBY1wDgDyyTPqZAfAtewjY0Vr3d6TIbDwPyPhjPhGTVmSDIe9m6UVrkqKS4RPuzKkVPbt2CezIBafSHL1Gh03KRJpuNW8kpb5h1t41Yg7Cr5kdxzStq6q0V1BRGajqGVVLHC1k0/uYeyc1wPaFvogFu9r2YDHevJ7zDJor9i+QZQy80drdSXjH7e6u7R1NRR08kEvL1jIexzml2mkSE7B2tmETNGud9rLtiOecV6l+JZDc2ZfaaKS1G2291Q10rKWSF8Mr27bC8OLT6ZAIPQldHAvEr3acpxaavs1fRRQ8KbRbpZKmlfG1lUySQvgcSOkjQRth9IbGwtk0TN1jVrCsKv9Pw48mylks9yoq21VLu/h1JI2SgBt9UzmlBG4xzOaPS11IHvrC8HuGVBRWXHMEzPFOIkt7tlW1k8jLhXvsLnRSmSKqa7thByEtY/lA5g4+56bW36KZo1Yv2GX6byf+LNvjsVxfcazN6utpKVtJIZp4jdIpGyxt1tzS1pcHAEaG96Cm9LJV4f5QGd+yeNXm42jLKO2ijraCgfU0v3GOWOWOZ7ekR9IH0tAgq8EVzRr55Or73asrrbDarXlNv4aUtrY+kpcvoTT1Fuq+113WB7vSlhEezsl4bytAeQVdVR+/wCxj+ZV/wBxqzKxEsZkz3G+Ub5Iat7ung3lY3f9rmj51tw4tf7Ve0rCdIiLykEREBRTil+8W5fLF/isUrUW4nsL8Euuh0Y1kjum9NbI1zj8wBK6cm+vR9492VO2HgyympazFbzBXW+W70UtFMye3wN5pKqMsIdE0bGy4baBsePiFqRX4pnOX8Oskxqw23LKzC7NJaa+0UWSwut10k7GfnnoYpPRe9rWMYWSHqHAAOOgVuYi6Jpuxay01qdPj2X5Tw/xvPqbPKCxyUltq82mrJHHtXB74oI6uV3M5pia73PKXcuidlRDzauVbkuWV9ssvECvorlw6udqbX5TT1Mk9RXba8RNjft0WwTytDWMc7mDAVuSixzRr5m+I3mm4S8HK+isFXdBiFXbLjX2Cni1UuijpXRO7OJ2uaSJzw4M6HbT7+l6eImQVfFa3YPUWvFMlo4rfm1qmlbdbTJTv7Fpc583ZuHM2NuwC5waAfi6q+kVzRVmCWWvovKA4qXCahqYLdXUVlFPVSQubFUPZHUiTkeRpxbtgOidbbv3lPKf/efZ/wCp6/8Ax6NZdYqkYX8S7a8HYitFYHjR6c01Ly/28jv7Fto1RV9p9pWE4REXlIKIcW7fdbrw3v8ASWTIYsUustPy096ndyspHcw9Mn5Nj51L1XvlBeavtM5Z57d681O5/th3LfbdlzN9zrrvekE0sUU0Fkt8dTVCuqGU8bZKpp2JnBo28fKevzounFu5+bNo9juf2P7nD3btPddlyDk38etIgyiidVw/Z28j7ZerlY4XuL3UtEIHQ8x6ktbLE/l2eumkDZJ1sqWIttGJVh/6ysTZDvMCv+Gd7+hof1ZVn5SVdkXBzgjlOY2bK7jVXK1RRSQw11NSOhcXTRsPMGwNJ6PPgR10r9VC+XZ/BO4g/wBGp/8ANQrbpOJy6R2W8p/bMKuVbbaSofmV6D5oWSODYKLQJaCdf6uvV5gV/wAM739DQ/qykVg/2Fbf6NH/AHQvemk4nLpHYvKPWjDILdXR11VX1l4rIgRDLXGPUOxpxY2NjGhxHTm1vRIBAJBkKItFddWJN6pTaIiLBBERAUXrsEjlqZ5rddrhZO3e6WWKi7J0bpHHbnhssbw0k7J5dAkucQXEkyhFsoxKsOb0yt7Id5gV/wAM739DQ/qyeYFf8M739DQ/qymKLdpOJy6R2W8od5gV/wAM739DQ/qyeYFf8M739DQ/qymKJpOJy6R2Lyh3mBX/AAzvf0ND+rJ5gV/wzvf0ND+rKYomk4nLpHYvKHeYFf8ADO9/Q0P6sqq4VXfJc54pcVcarsqr4aHFK+kpaKSnpqQSSNlg7RxkJhIJB8OUN6etbDLXbydv4QnlFf1zbv8AKFNJxOXSOxeVseYFf8M739DQ/qyeYFf8M739DQ/qymKJpOJy6R2Lyh3mBX/DO9/Q0P6snmBX/DO9/Q0P6spiiaTicukdi8od5gV/wzvf0ND+rJ5gV/wzvf0ND+rKYomk4nLpHYvKHeYNf8M739DQ/qyzFhxemsL5Zu3qK+ulAbJW1jmulc0eDfRDWtb4nlaANknWysyixqx8SuM2Z1coiPZLyIiLnQREQFwliZPE+ORjZI3gtcxw2HA+II98LmiCHv4eOhPJb8ku9spR7ilhFPIyMepplhe4Ae8ObQ8AvnmBX/DO9/Q0P6spii6tJxd8dI7Mryh3mBX/AAzvf0ND+rKquNl3yXhtkXDKgtuVV88OT5JDZ6x1VTUjnRwvY5xdHywjTvRHU7HxLYZa7eVd+/fgJ/56pv8ADkTScTl0jsXlbHmBX/DO9/Q0P6snmBX/AAzvf0ND+rKYomk4nLpHYvKHtwKva4E5je3AHejDRaP/AOOs1YMbpcfbM6N81VVz8vb1lU4Oll5RpoJAAAGzprQGglx1txJyyLCvHxK4zZnVyiI9kvIiItCCiHFu4XW1cN7/AFdkx6LK7rFT81PZZ28zKt3MPQI+TZ+ZS9RDi3b7rdeG9/pLJkMWKXWWn5ae9Tu5WUjuYemT8mx86CQ2KWaeyW+SppRQ1D6eN0lK0aELi0bYPkPT5kSxRTQWS3x1NUK6oZTxtkqmnYmcGjbx8p6/OiD3IiICoXy7P4J3EH+jU/8AmoVfSrzyg+F9Txn4OZLhlJXRW2pusMccdVMwvYwtlZJ1A69eTXzoJpYP9hW3+jR/3QvetdIsm8pLBoY4a3AcLzujhaGMGOXeW3zFgGhsVQcN6HvFcv8ATD83PRznhVnuH8v7pWexffaJn/zoid/ioNiUVQYn5XnBvNHNZbuIVmilcdCG5Smhfv1cs4YSfiCteguNJdaVlTRVUNZTP6tmp5A9jvkI6FB6EREBERAREQEREBERAREQFrt5O38ITyiv65t3+UK2JWq97kzbyZ+L+fZx5oy5rw/y2pp6utnsLi+42oxRCPmdAddoz3RJaeg6kjWiG1CKIcM+LeI8YbA284hfKW80fQSCF2pYHH/hkjOnMd8TgP7FL0BERAREQEREBERAREQEREBERAWu3lXfv34Cf+eqb/DkVxcQuJuLcKcflveW3uksdtZsCSpf6UjvHljYNue7+S0E/EtdvZLMfKw4g4Df7ViU2J8N8WvMd6hvGQExVl1LGkAQ042WsIdsOcdEHYPTlQbYIiICIiAiL4XAEAkAk6G/fQfVXvlBeavtM5Z57d681O5/th3LfbdlzN9zrrvel3DjVi9xGZwY/VSZZeMSjLrnZ7Kztqpsg7TULAdNfITE9vKHb2NHRIWAv1z4i8SOHuPVeN4/ZscqrhMTdrTm8Mkr4KYOPohkWwXu5QdO6AO9YQWPi3c/Nm0ex3P7H9zh7t2nuuy5Byb+PWkXvpI3Q0sMbmxtcxjWlsQ0wEDwaPeHqRB2oiICIiAiIgiuW8KcLzwOGR4nZb45w0X3CgimePkc5pIPxgqp6/yGOFYqn1mO0t5wa4POzWYxeKileD8TS5zB8zVsEiDXX/R/4uYn1w/j1dqiBnuaLLrZDcuf1B0/ovHygL550+U1hv8AtHCcK4gwM8HWC6yW6d49ZFQC3fydFsWiDXP/AExJMb9HOuEue4jy+7rGWzv1Ez1/doj1+ZqlOJ+WFwazRzWUHEG0QTOOuxucjqF+/VqcM6/IrjUXyzhbhudtcMjxSy30uGua4UEUzh8jnNJHyhBnrdc6O8UjKqgq4K2mf7mamkbIx3yOBIK9S1/uPkM8KHVb6yw2+64VcXeNZjV3qKR49Wm8zmD5mrz/AOj3xVxTrh3Hu+SQs9zSZdboLrzj1OmPK8fKBtBsQi1484fKZw//AMdieD8Qqdvh7C3KW2VLx/K7cFgPydF9/wBLauxz0c44P57i/L+6VdLb23Kjj+WaF3/9UGwyKm8W8sPg1l8gipM/tVHUb5TBdnOoHtd/y6nazr8itm2XahvdI2qt1bT19K/3M9LK2Rh+RzSQg9aIiAiIgo/iZ5Kdgyu/uy7ELjV8N8/btzb/AGHTGzne9VMHRkzSfHeiffJHRRii8ozMeCdXDaOOuPCmtxeIqfPceidNbJt9G94jA56dx+TRJOmgDa2XXTWUdPcaSalq4I6qmmYY5YZmB7HtI0WuaehBHvFB5rHfrbk9pprpZ7hTXS21LOeCro5WyxSN9bXNJBXvWuF98lq68OrtU5JwLyEYTcJX9tVYvXB01jr3eoxeMDj4czPAdAG+KojyovLmzbEMIgw+bF7lw64pitpqmon5mTUndY3l4lppg/0xJJE1ha5j2FnbMdtB+gyKovJe4+0HlEcKqDIoezgvEOqW7UTD+4VLQOYgf8jhpzfHodb2CrdQEREBERAREQEVc+UBxqtPALhfdctuhZLJC3saGjLuU1dU4Hs4h8XQkkeDWuOjpaW+S95fOYXm0XTErjYrhxC4iXK5yT2GGOeOGJ8cjXySxSPIHZxwlr3A7d6L+QcjIwg/Q26XWisduqLhcqyC30FOwyTVVVK2OKJo8XOc4gAfGVrvdPKYyTi3cKixcCMdF/ax5hqc1vLXwWakPgez6c1Q4epo14HTgUtXky5FxZuNPfuO+RNyIxvE1Nhdnc6Cy0Z8RzjfNUOHrcfWPSC2Jtlro7Jb6egt1JBQUNOwRw01NGI44mjwa1rQAAPUEFI8PfJPtFsyGPL+Id2qeJ2d9HC53lo7rSHe+WmpvcRtB6joSD1HLvSvhFEOIfFnFOFeLTZFk13ZQWiKcUzqiON8/wB1O9M5Y2udzbBGtIJeihFbnt79sKwWS2YdW3XHLhRmsqsobUxxU9GCH8jOzd6cjiWs6DwEgPXRCxdHhWdZJbc5teYZVBT0N1kfDZpcVY+jq7bT7eGvMziSZS0xk9NAtd1IKCaX/L7Fir6Fl6vNvtL6+dtNSNrqlkJqJXEAMjDiOZxLgNDZ6hR+i4r2+58ULtgdLa7u6622iFZNXy0T2W48wjLYxUdQXkSA6A/4Xe+NLhbuC+Kw45jNnu9CMtbjp56CtyMNralkm99oXub1f4elr3h6lOkFTU8HFXiTwwrYa+Wl4R5bPWagkoTFeDBSjlJB5tMMjgXjY8NAj1LPVnBuw3vL8Vyy995umTY7TdhSVveZYY+ctLXymFjgwucHP8QejtepTtEHlorVRW19Q+ko6elfUSGWZ0MTWGV58XO0OpPrK9SIgIiICIiAiIgIiICIiAiIgIiICIiAiIgj2U8O8VziMx5FjVovzCNauVDFUa+TnadKp7n5EXCWerdW2ey1uIXJ3hW43c6iie35GtfyD8VXyiDXweTvxJxbrh3HnJGRN8KbLKKC8B/8kyODHj5R1X0XfylsQ61WP4JxBpWeAtlbPa6uQfyu1Dowfk6LYJEGvw8qm8456ObcGc7x0t/dKm20kd2pI/WXSwO8Pj5Vm8a8sXg3lE/d4c7t1uqweV1NeQ+3vY7/AJSJ2s6/IrmWEyTCMdzODsMgsFsvkOtdncqOOobr5HtKD3Wm9W+/Ubau2V9NcaV3uZ6SZsrD8jmkheLNcrpMEw2/ZLXxzTUNmoJ7jUR0zQZXRwxukcGBxALiGnWyBv3wqmuvkVcI62rdW23HJsWuR9zW45Xz0D2fI2N4Z/6VQflncPb3wO4BXqopONOaVdtuksNqZZL5K2tNaZHcz4e8hgkjb2TJXHZ05rCwk8+iG2/CHjRiPHLFIr/iN0ZX0p02eneOSopJCNmOVni1w6+sHW2lw0VqTx2/Y5cm4w8UslzSTiHRSz3ar7WOCotpi7CANDIotscQ7s42sZzaBdy8x6krSPyd7hxWxnOYLxwst14rbtAQ2eK30kk8MsexuOdoGiw9N82tHRBBAK/Vet4sZTcuHuPU94sr8Oy+6UzprnQsmEjqKNsjmAsePcmXl2332N5hsOaCunJ8CvKcWMKjbI1l8nTgjl/kk8XZqpmU2rJ7NUxSUt3tVp7XmOmuMJLntbG17JOXY5i4Nc8a69dqX8ebp/7PFqYjZ/dLqWnXzQFV1DDHTxNiiY2ONo01rRoBc19thfCMkoptVTnTvmZ/EwX5LA9vm8/BWi/LD/1dPb5vPwVovyw/9XVfot3yvIv2/WruZ3JYHt83n4K0X5Yf+rp7fN5+CtF+WH/q6r9dNdXU1so56usqIqSkgYZJZ53hkcbQNlznHoAB75U+WZFH/X61dzO5LG9vm8/BWi/LD/1dfW8ersPd4rS6/kXZxP8A3gCryORk0bZI3NfG8BzXNOwQfAgrknyvIv2/WruZ3JVflPcKcp8rPiDZI2ZVbsUx+jpxFSWy7xyAd4c49q5skfOx5cBGG85jJ1yhnQudj+Hf7HNknBvK7ZnT+KlrsMmPzi4Gr7lK6ERsBL2y/doj2Tm7Y8c42xzgT1VxSRsmjdHIxr43Atc1w2CD4ghT/C6i28R8funD3MaOG+2yoptxxVrecTwAtBY4+PPG7kc14Id1aQeZhcfC+I/CqcGicbA2RtjsbWhPlpeXFVcZ56nDsLmnoMGjdyVNQQWS3RwPQuHi2IEbDD1Pi7rprdx/J54v55xV4IcKLpitNZLq9pFtyyqvd0kmqYO7OZG5+2jmM80YMoDx07VhPMDzHH5Z+xrcFMhg5LdbbrjEg8JLZcpJNn4xP2v/AG0rX8nfyfrJ5N+D1WM2K4V9ypqqvkuMk1wcwv7RzI49N5WtAbyxN9Z2XHfUAfMDM0XD66jP8ivd0y+4XawXOkFHTYxLDGyko2lrA9wc0cz3Etf1JGhIR10CvZw84VYrwqxanx3F7PDbLPTymeOn5nS6kOtvLnlzi7oOpKliICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAsPlGG4/m9DDRZHY7bf6OGYVEVPdKSOpjZKAQHta8EBwDnAEddOPrWYRB00dHT2+lipqWCOmp4m8scMLAxjB6gB0AWvfEOd1TxKyMvOzA+np2ePRggjeB+NI8/OtilRvGOwyWrMIrs1n+p3WJsTnjwbURg9D/Oj1r/AOE74l7/AMFrppymYq84mI6xPtC+UoWi8t0lrYLfNJb6aGsrWj7lBUTmFjzvwLw1xb+KVFxes99/E7H82QS/qi+2qrimbTfpMtaVXGtZbbfU1cjS5lPE6Vwb4kNBJ1/YqJwrOuJWTeb1/hoLnU0FznhlnopKWhZQRUkhG3Ryibty5jTvbgeYggtbvpaNHdc0nq4Yq3GLLBRveGzSx3ySVzGE+kQw0rQ4gb6EjfrC8WMcIqDELjTy2y93yC100r5YLJ3wGiiLt7aG8vMW+kSGlxAPXXRcmJFeLVTNEzER/G7fGtVfMzjMabF6jLpchE9LRZK+2OtXcoWxy0xr+7+k8N5+cBw04EDTRsE7J8/Ee65LnmFcUq6nvrbRYbK2ttbLXHRxyGr7KL7q+WR3pN5i4hvJrQAJ2rOk4TWiTEavHTU1vcqm5G6PkD2doJTVCp0Dya5eca1rfL7++qx2ScDLRkNVfnsvF7tFNfWkXGhttU1lPUPLOQyFrmO04gDeiA7XUHqtFeBjTTm3vq16522n02ahNsc/e9a/6LF/cCyCh8tXmFqeKO245aay307RFBPU3uSKSRgAALmClcAfiBK4ezWffBOx/wD3BL+prujEiItMT0nsiZrKYbM6mz7GZWe7NW6I699roZAR/wDz8yj1mnuFRbopLpSQUNcd9pBTVBqI29TrTyxhOxo+5Gt6662pzwmsL73m8VcWnulnY6Vz9dHTyMLGN+PTHPcfVtnrC1ZXiU0ZNiVVbLT6xZlTtX0iIvzNRERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBeG9WWjyG2T2+4QCopZgA5h6dQQQQR1BBAII6ggEL3IrEzTMVUzaYFAZJwxyHGpnmmppL/bgSWT0waKhrfVJF02fjj3vx5W+CjEjKqE6ktV1jdvXK+2VDT/YWLaZF9HhfHMWmm2JTEzv2Lqarc0/4Ouf5On+wnNP+Drn+Tp/sLalFu+e1ft+v9JaGq3NP+Drn+Tp/sJzT/g65/k6f7C2pRPntX7fr/RaGq3NP+Drn+Tp/sLk1tVIdMtd1kd/ystlQ4n5gxbTonz2r9v1/otDX3HeG2RZJKwyUb7JQk+nU1oAlI/kReO/5/Lr4/BXhjuPUWL2mG30EXZwR9S5x2+Rx8XuPvuPvlZJF4+V5fjZZqr1RHlCiIi81BERAREQEREBERAREQEREH//2Q==", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Image, display\n", + "from langchain_core.runnables.graph import CurveStyle, MermaidDrawMethod, NodeStyles\n", + "\n", + "display(\n", + " Image(\n", + " app.get_graph().draw_mermaid_png(\n", + " draw_method=MermaidDrawMethod.API,\n", + " )\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAGWAeYDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBAUCAwgBCf/EAGAQAAEEAQIDAgcHDwcJBAgHAAEAAgMEBQYRBxIhExQIFiIxUVaUFUFUlbTS0xcjMjY4U1VhcXR1dpKT1AkzN0KBstEkJTQ1UpGhsbMmYnOCRWNylsHDxNVkg6KjpcLw/8QAGQEBAQADAQAAAAAAAAAAAAAAAAECAwQF/8QAMhEBAAECAQsCBAcBAQAAAAAAAAECEQMSExQhMVFSYZGh0QRBI3HB4TNCU4GisfAyIv/aAAwDAQACEQMRAD8A/VNERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERARFq87mji2QQ14O+ZG04x1qwdyhxA3Lnu2PJG0dXO2O3QAOc5rTlTTNU2gbNzgxpc4gNA3JPvLXSalw8Ti1+VoscPOHWGA/8ANatmiK+Re2fUEpztrcO7Odu1WIj3o4dy0Df33czvS47BbCPSOCiYGMwuPY0eZrasYA/4LdbCjbMz8o8+F1OXjVhfwxQ9pZ/injVhfwxQ9pZ/inirhfwPQ9mZ/gnirhfwPQ9mZ/gnwefZdR41YX8MUPaWf4p41YX8MUPaWf4p4q4X8D0PZmf4J4q4X8D0PZmf4J8Hn2NR41YX8MUPaWf4p41YX8MUPaWf4p4q4X8D0PZmf4J4q4X8D0PZmf4J8Hn2NTtr5/GXJAyDI1J3k7Bsc7XE/wBgKz1prOi9P3IjHYwWMnjO+7JKcbh/uIWB4rz6cAn09NKyFnV+ImlL68o98Rl25id6OUhnpb13DJwqtVMzE89nX7JqShFhYjKwZqhHagD2tdu10creV8bwdnMcPecCCCPxLNWiYmmbSgiIoCIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgKL6b2yupdQZSTZxgmGMrf9yNjWuk/IXSOdvt5wxm/m2EoUY0YO6XdSUHbiSHJvmG425mStbIHD0jdzm/laV0Yf/Fcxtt2usbJSdERc6MTL5algMVdyeRsxUsfShfYsWZncrIo2NLnPcfeAAJJ/Eqc1t4V+ksJwmz+ttPG1qBmMMLBWfQt1ud0p+tuPPDuGEbuD9uU7bb7kKz9f0qGS0LqGplMVYzuNnx88dnF1Gc01uMxuDooxuN3OG7R1HUjqPOvLMuC15rLgbxX0ljsdqfJaaq0afiw3VlHumVkcx3aT1dnBrpWsEbAx727ku25n7boPQmW48aMwWlsbqHIXshTx2SlfBVbLhrosyPYTzDu/Y9sNuUncsA26+Ygrjf8ACB4fY3TWntQTakgOH1BK+DGWoYZZRZla17nRgMYSH+Q5vKQCXDlA5iAq44na8zus/Em7TxPEDC6GsTW2ZyHE4qxWzHatjjNZhYwdvHC5xl5nx7dWtBcAdzAOFmhM9Wm4ZVbmls/ThxXELN3ZmZWvJLJXry1rMsEssvlNcCZYx2nMQZNxzFwKC4meE/p+Xi3iNGx0ct3bJYluQivOw98P7V87Io43RGDeNmzi50jyGtOwdylXOqP1zYyGifCSwerJNPZrMYK7pmbCGxhaL7jq9nvccre1awEsYW7+Wem46q8EBERBGKu2I4gWajNmwZemb3IN/wCehcyOR3o8pskA/wDIpOoxYb33iRR5QeXH4ucyHbpvPLEGdfyV5On5FJ10Yv5ZnbbX9O1lkREXOgiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiIC0Gbx1ink4s5joe3tRxdharA7OswAlwDT5u0Y4kt36Hme07c3M3fos6Kpom8Kj2Qx2muJ+npaWRpUs/iJXN7alegEjQ9pDg2SJ43a5p2PK4Ag+8FGB4NnCcb7cN9LDfz/5og+apdltIYvMW++SQvr3wAO+05n15iB5gXsILgOvku3HU9OpWD4kTN6M1PnmNHmHeI3f8XRk/wDFbcnCq2VW+cePEGprMHwI4caZy1bKYjQmnsZkqzueC3UxsMcsTttt2uDdwdifMp2ov4k2PWrPfvofok8SbHrVnv30P0SZvD4+0lo3pQionwmMhnuEnAzVersHqjLPyuMgjkgFt0T4iXTRsPM0Rgno4++rDxGk7d7E0rMmqs72k0DJHcssIG5aCdvrX40zeHx9pLRvTNVxJ4N3CmV7nv4caWc9xJLjiYCSfT9it/4k2PWrPfvofok8SbHrVnv30P0SZvD4+0lo3o+7wbOFDnEu4b6Wc49STiYCT/8ApUuyGZoaZgq4+vE19oxiOni62we9regDW/1WDpu47NaPOVg+IzpAGzajz0zPfb3tse/9sbGn/cVtcLpvG6eZIKFURPl2Msz3OkllI8xfI4lzv7SUthU65m/+3/Y1OrTmGlxkVmxceyXJ3pO3tSR78gdyhoYzfryNa0AenqdgXFbhEWmqqa5vKCIixBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQUR4dH3KHEL81g+UxK5tOfa9i/wA1i/uBUz4dH3KHEL81g+UxK5tOfa9i/wA1i/uBBsUREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQUR4dH3KHEL81g+UxK5tOfa9i/zWL+4FTPh0fcocQvzWD5TErm059r2L/NYv7gQbFERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQERaXUWojhjXrVq3fsla5jDW5+RvK3bme9+x5WjmG52J3IABJWdFE1zk07RukUJOd1gf/AEfgx+Lvcx2//aXz3d1h8Awftc30a6dFr3x1hbJuihHu7rD4Bg/a5vo093dYfAMH7XN9Gmi1746wWTdFCPd3WHwDB+1zfRp7u6w+AYP2ub6NNFr3x1gs/M/+Uh4FHhvxh8b8dXLMFqwvtPc0eTFeB+vt/Fz7iTr5y9+3Rquv+Sx4LS4rC53iffY6N+Ta7EY1p3AdA17XTSeggyMY0egxP9KvzwguFGX8Ibhva0nmK2Gph00dmreisSvkqzMPR7QY9ju0vaR6HnzHqphovG57QOksPpvD4rBwYzF1Y6ldne5t+VjQNyey6uO25Pvkk++mi1746wWWgihHu7rD4Bg/a5vo093dYfAMH7XN9Gmi1746wWTdFCPd3WHwDB+1zfRp7u6w+AYP2ub6NNFr3x1gsm6KEe7usPgGD9rm+jXJmf1cw8z8ZhpWjzsZdlaT+QmI/wD+/wB6aLXvjrBZNUWuwWbgz+PFqFr4iHuilglGz4ZGnZzHAdNwR5wSCNiCQQTsVy1UzTM0ztQREWIIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgKEZw78SKQ9GJm2/F9ej/wAApuoRm/6Saf6Jl/60a7PS/wDc/Kf6WG0RVNx41xltMXtEYfH5uHSdXUGUfTuajnhjkFNrIJJWsaJQYw+VzAxpeCB16E7KnqHHDX3i/j8LTylzU2ZzurMnjKOfx1Gm58tCnEHGWrE90UDnOLSN3uc3cSkc2zWrZNURNkeuJJGQsc+RzWMaNy5x2AC5LyBxWs8SM1wH1pT1Y7L4mrSymIkx2TyFXHxW70T7cTZIpo4Hyxjs5OR4c3kLvJBG3MD6t03jLuGwtankMxZz1yIOEmRtxRRSzbuJHM2JjGDYEDyWjoBv13KsTcbJYdjM0KmSp46e9WhyFxsj61SSZrZZ2sAMhYwndwbzN32HTmG/nVacWNV6jk19ozQemMpHp61nYrl23mXVmWJK9euI/Ihjf5Be90rRu4ENAJ2KgvEDSurW8XeDuJ8eJHZs1c/zagOLg7bsuWqQBFt2XPtsOYtI855fRJqsPSK4ySMiYXvc1jR53OOwC81t4takm4ey4u7qq5W1jU1Xc05BawWFgtXcwIOZ28deT61E7kLXPe7yGhjvNzDaHaw1bqviV4PGRizmUu4zM4HXdLETWDTrRz2GtuVjG6aMdpGx7e2Y4iM8pdEPO0kFlD2Oi1+Ax1vE4erUvZSfN24m8sl+1HFHJMdz1c2JrWD0eS0Doq34p6o1Le4i6U0BpfLM03YylW1lL2ZNVliWGvAY2iOFkgLC975RuXA8rWk7HdZTNhZmOzOPzBtiherXTUndVsCtM2TsZmgF0b9ieV4BG7T1G49KzF5A4e6h1bg8tkdDYzPshz2o+IWXgtalkoxufHFXpQzSOZCfrfav5WgbgtG7jy+iR5TjJrfS1vOaAdlquV1THqbF4GhqWzTYwMhvQOnEssDOVjpI2xygBvK1xLNwOu+EVj04ioriPU4paA0TDJj9W5PVLn5SDv8AkKuDqvyFGhyP7V0EDGhkzufs+nIXBpdsHFWXwtz8Gp9A4fJ1tRs1ZDYjcRmGVxX7xs9wPNGAAxwI5XN2GxadwD0WUTrsN7w9O9rVg94Zc7AD/wDC1z/zJUwUO4ef6Zq39Mf/AEtZTFaPU/iz+39QsiIi5UEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQFCM3/STT/RMv/WjU3UU1XjLUGXp5ypWkvdhBJVsVodu0MbnMcHsB23LS3q3cEhx23IDT1emmIr1+8T/Swq3wkNE5LXOkcZUxuJymaMGQbYlq4rI1KshaGPAJbbjfDKASCGvA2OzgQWhR3RHBfPax0AMbxDsZHHWsble+6btVrdduVxUTY2tbzTV42wl25l6Brm8rgDvt0t06yrjocXnQfR7i2z/yjTxzrfgzPfElv6JdmYrmb5MmTKOz8F8blOHeZ0fnM3ntR08s/tJ72Uuh9prxyFhjc1rWx8ro2OaGtADgTsdzv1QU9d6IrQ4rC04NbU2N5zltTahNa45xJ3aWxUnN5R02O4/J6ZP451vwZnviS39EnjnW/Bme+JLf0SuYxOGTJlEM3w4ucWKWOt6squ0bqPD2ny4vJaWzLp7Fdr2BryJH12DZ43a6NzHNIaD+TPw/ByhjM5pnMWc7nc1k8A282CzlLTJnTd67PtO08gdG9k3lDOUDr0PvSDxzrfgzPfElv6JPHOt+DM98SW/okzFfDJkyhd3wd8DY7Warl83isp7vWtQwZSjYibYq2LDOSZjOaMtMbm9OV7XH8a41/Bv0zDozVGmZMhm7VDUN1mTszWLvPYiuN7M94il5eZry+GN/XdocOgA8lSfPcU8FpXEWcrmm5PE4ysA6e7exVmGGIEgAue6MAdSB1PnIWbFrinNGySPHZx8bwHNc3CWyCD5iD2aZivhlcmdyNRQa+0ZXixOHx1XWVKFpIzGo9RGvdlc4lxD2RUXM2G+wIPmA6LGznDK3xVhxOT1PHNonVGGsSnH5DSuYM8scT2tD2mSSuwFr9tnMdG4eQ079ekw8c634Mz3xJb+iTxzrfgzPfElv6JMxicMpkygVbwaNO1cBNQbmtQHIOzcmoYc6bjPdCtcfGI3uZII+Xlc0EFrmuBDiCNttslng46Vk0llcLdnyuSt5O/HlbOes3P8AOTrkfL2U7ZmhoY6MNaGhrQ0AbbbE7zTxzrfgzPfElv6JPHOt+DM98SW/okzFfCuTO5Em8Ephhn0ncRtcvtOsx2Rkjk4u3ZyMcwRgCHs+Qh5JaWHchpPVoUq4faDxnDXSdPT2INh9Os6STtbcvaTSySSOkkke733Oe9zj0A69AB0XPxzrfgzPfElv6JcmauilPLFic7JJ7zDiLEe//mexrR/aQmZrj8qWln8PP9M1b+mP/paymKj+jMNZxNG3Nda2O7kLLrc0TCHCIlrWtZzDzkNY0E+nfbopAuD1FUVYkzHLtBIiIudBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERBRHh0fcocQvzWD5TErm059r2L/ADWL+4FTPh0fcocQvzWD5TErm059r2L/ADWL+4EGxREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERAREQEREBERBRHh0fcocQvzWD5TErm059r2L/NYv7gVM+HR9yhxC/NYPlMSubTn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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "\n", + "\n", + "env = Settings()\n", + "chat_graph = get_chat_with_docs_graph(\n", + " llm=components.get_chat_llm(env),\n", + " all_chunks_retriever=components.get_all_chunks_retriever(env),\n", + " tokeniser=components.get_tokeniser(),\n", + " env=env\n", + ")\n", + "display(\n", + " Image(\n", + " chat_graph.get_graph().draw_mermaid_png(\n", + " draw_method=MermaidDrawMethod.API,\n", + " )\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/summarise.ipynb b/notebooks/summarise.ipynb index 4e5155bb2..4f1217071 100644 --- a/notebooks/summarise.ipynb +++ b/notebooks/summarise.ipynb @@ -355,7 +355,7 @@ " @chain\n", " def map_operation(input_dict):\n", " system_map_prompt = env.ai.map_system_prompt\n", - " prompt_template = PromptTemplate.from_template(env.ai.map_question_prompt)\n", + " prompt_template = PromptTemplate.from_template(env.ai.chat_map_question_prompt)\n", "\n", " formatted_map_question_prompt = prompt_template.format(question=input_dict[\"question\"])\n", "\n", diff --git a/redbox-core/poetry.lock b/redbox-core/poetry.lock index 2eeb8a94e..94b363498 100644 --- a/redbox-core/poetry.lock +++ b/redbox-core/poetry.lock @@ -495,6 +495,21 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "dataclasses-json" +version = "0.6.7" +description = "Easily serialize dataclasses to and from JSON." +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, + {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, +] + +[package.dependencies] +marshmallow = ">=3.18.0,<4.0.0" +typing-inspect = ">=0.4.0,<1" + [[package]] name = "distro" version = "1.9.0" @@ -876,6 +891,29 @@ requests = ">=2,<3" SQLAlchemy = ">=1.4,<3" tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0" +[[package]] +name = "langchain-community" +version = "0.2.10" +description = "Community contributed LangChain integrations." +optional = false +python-versions = "<4.0,>=3.8.1" +files = [ + {file = "langchain_community-0.2.10-py3-none-any.whl", hash = "sha256:9f4d1b5ab7f0b0a704f538e26e50fce45a461da6d2bf6b7b636d24f22fbc088a"}, + {file = "langchain_community-0.2.10.tar.gz", hash = "sha256:3a0404bad4bd07d6f86affdb62fb3d080a456c66191754d586a409d9d6024d62"}, +] + +[package.dependencies] +aiohttp = ">=3.8.3,<4.0.0" +dataclasses-json = ">=0.5.7,<0.7" +langchain = ">=0.2.9,<0.3.0" +langchain-core = ">=0.2.23,<0.3.0" +langsmith = ">=0.1.0,<0.2.0" +numpy = {version = ">=1.26.0,<2.0.0", markers = "python_version >= \"3.12\""} +PyYAML = ">=5.3" +requests = ">=2,<3" +SQLAlchemy = ">=1.4,<3" +tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0" + [[package]] name = "langchain-core" version = "0.2.23" @@ -943,6 +981,20 @@ files = [ [package.dependencies] langchain-core = ">=0.2.10,<0.3.0" +[[package]] +name = "langgraph" +version = "0.1.14" +description = "Building stateful, multi-actor applications with LLMs" +optional = false +python-versions = "<4.0,>=3.9.0" +files = [ + {file = "langgraph-0.1.14-py3-none-any.whl", hash = "sha256:e093cf10a0b8998a365a9f9b24e9b73d88df1c0725ba258ed4744bbb592cf7a2"}, + {file = "langgraph-0.1.14.tar.gz", hash = "sha256:e729d4fb77acf85391324bce87c48b37a45035693a1d21492f91e2bedade0078"}, +] + +[package.dependencies] +langchain-core = ">=0.2.22,<0.3" + [[package]] name = "langsmith" version = "0.1.93" @@ -1031,6 +1083,25 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "marshmallow" +version = "3.21.3" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "marshmallow-3.21.3-py3-none-any.whl", hash = "sha256:86ce7fb914aa865001a4b2092c4c2872d13bc347f3d42673272cabfdbad386f1"}, + {file = "marshmallow-3.21.3.tar.gz", hash = "sha256:4f57c5e050a54d66361e826f94fba213eb10b67b2fdb02c3e0343ce207ba1662"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] +docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.3.7)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] name = "moto" version = "5.0.11" @@ -1174,6 +1245,17 @@ files = [ {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "numpy" version = "1.26.4" @@ -1221,13 +1303,13 @@ files = [ [[package]] name = "openai" -version = "1.36.1" +version = "1.37.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.36.1-py3-none-any.whl", hash = "sha256:d399b9d476dbbc167aceaac6bc6ed0b2e2bb6c9e189c7f7047f822743ae62e64"}, - {file = "openai-1.36.1.tar.gz", hash = "sha256:41be9e0302e95dba8a9374b885c5cb1cec2202816a70b98736fee25a2cadd1f2"}, + {file = "openai-1.37.1-py3-none-any.whl", hash = "sha256:9a6adda0d6ae8fce02d235c5671c399cfa40d6a281b3628914c7ebf244888ee3"}, + {file = "openai-1.37.1.tar.gz", hash = "sha256:faf87206785a6b5d9e34555d6a3242482a6852bc802e453e2a891f68ee04ce55"}, ] [package.dependencies] @@ -1498,6 +1580,24 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -1621,90 +1721,90 @@ files = [ [[package]] name = "regex" -version = "2024.5.15" +version = "2024.7.24" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" files = [ - {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"}, - {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"}, - {file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"}, - {file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"}, - {file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"}, - {file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"}, - {file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"}, - {file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"}, - {file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"}, - {file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"}, - {file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"}, - {file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"}, - {file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"}, - {file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"}, + {file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"}, + {file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"}, + {file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"}, + {file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"}, + {file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"}, + {file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"}, + {file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"}, + {file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"}, + {file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"}, + {file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"}, + {file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"}, ] [[package]] @@ -2128,6 +2228,21 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +optional = false +python-versions = "*" +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "urllib3" version = "2.2.2" @@ -2279,4 +2394,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.12,<3.13" -content-hash = "7c60a34708c72a079297eff8ce909647aba315a4e9a66a3a26f694ff3842b9da" +content-hash = "5db177c2d1b635b0c2b1a0c3bd36c7ddef4aed2492a628dae4627db0799133c5" diff --git a/redbox-core/pyproject.toml b/redbox-core/pyproject.toml index 3a5d566c4..e2e6b1c19 100644 --- a/redbox-core/pyproject.toml +++ b/redbox-core/pyproject.toml @@ -14,6 +14,7 @@ readme = "../README.md" python = ">=3.12,<3.13" pydantic = "^2.7.1" elasticsearch = "^8.14.0" +langchain-community = "^0.2.6" langchain = "^0.2.11" langchain_openai = "^0.1.9" tiktoken = "^0.7.0" @@ -22,12 +23,14 @@ pydantic-settings = "^2.3.4" langchain-elasticsearch = "^0.2.2" pytest-dotenv = "^0.5.2" kneed = "^0.8.5" +langgraph = "^0.1.9" [tool.poetry.group.dev.dependencies] pytest = "^8.3.2" moto = "^5.0.10" pytest-cov = "^5.0.0" +pytest-asyncio = "^0.23.6" [build-system] requires = ["poetry-core"] diff --git a/redbox-core/redbox/chains/components.py b/redbox-core/redbox/chains/components.py new file mode 100644 index 000000000..805fce061 --- /dev/null +++ b/redbox-core/redbox/chains/components.py @@ -0,0 +1,87 @@ +from langchain_elasticsearch import ElasticsearchRetriever +from langchain_core.embeddings import Embeddings, FakeEmbeddings +from langchain_openai import AzureChatOpenAI +from langchain_openai.embeddings import AzureOpenAIEmbeddings, OpenAIEmbeddings +from langchain_core.utils import convert_to_secret_str +from langchain_core.runnables import ConfigurableField +import tiktoken + +from redbox.models.settings import Settings +from redbox.graph.retriever import AllElasticsearchRetriever, ParameterisedElasticsearchRetriever + + +def get_chat_llm(env: Settings): + return AzureChatOpenAI( + api_key=convert_to_secret_str(env.azure_openai_api_key), + azure_endpoint=env.azure_openai_endpoint, + model=env.azure_openai_model, + ) + + +def get_tokeniser() -> tiktoken.Encoding: + return tiktoken.get_encoding("cl100k_base") + + +def get_azure_embeddings(env: Settings): + return AzureOpenAIEmbeddings( + azure_endpoint=env.azure_openai_endpoint, + api_version=env.azure_api_version_embeddings, + model=env.azure_embedding_model, + max_retries=env.embedding_max_retries, + retry_min_seconds=env.embedding_retry_min_seconds, + retry_max_seconds=env.embedding_retry_max_seconds, + ) + + +def get_openai_embeddings(env: Settings): + return OpenAIEmbeddings( + api_key=convert_to_secret_str(env.openai_api_key), + base_url=env.embedding_openai_base_url, + model=env.embedding_openai_model, + chunk_size=env.embedding_max_batch_size, + ) + + +def get_embeddings(env: Settings) -> Embeddings: + if env.embedding_backend == "azure": + return get_azure_embeddings(env) + elif env.embedding_backend == "openai": + return get_openai_embeddings(env) + elif env.embedding_backend == "fake": + return FakeEmbeddings(size=3072) # TODO + else: + raise Exception("No configured embedding model") + + +def get_all_chunks_retriever(env: Settings) -> ElasticsearchRetriever: + return AllElasticsearchRetriever( + es_client=env.elasticsearch_client(), + index_name=f"{env.elastic_root_index}-chunk", + ) + + +def get_parameterised_retriever(env: Settings, embeddings: Embeddings = None) -> ElasticsearchRetriever: + """Creates an Elasticsearch retriever runnable. + + Runnable takes input of a dict keyed to question, file_uuids and user_uuid. + + Runnable returns a list of Chunks. + """ + default_params = { + "size": env.ai.rag_k, + "num_candidates": env.ai.rag_num_candidates, + "match_boost": 1, + "knn_boost": 1, + "similarity_threshold": 0, + } + return ParameterisedElasticsearchRetriever( + es_client=env.elasticsearch_client(), + index_name=f"{env.elastic_root_index}-chunk", + params=default_params, + embedding_model=embeddings or get_embeddings(env), + embedding_field_name=env.embedding_document_field_name, + ).configurable_fields( + params=ConfigurableField( + id="params", name="Retriever parameters", description="A dictionary of parameters to use for the retriever." + ) + ) diff --git a/redbox-core/redbox/chains/graph.py b/redbox-core/redbox/chains/graph.py new file mode 100644 index 000000000..6c0e1c892 --- /dev/null +++ b/redbox-core/redbox/chains/graph.py @@ -0,0 +1,117 @@ +import logging +import re + +from langchain.schema import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable, RunnableLambda, RunnableParallel, chain +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.vectorstores import VectorStoreRetriever +from tiktoken import Encoding + +from redbox.api.format import format_documents +from redbox.models import ChatRoute, Settings +from redbox.models.chain import ChainState +from redbox.models.errors import QuestionLengthError + +log = logging.getLogger() +re_keyword_pattern = re.compile(r"@(\w+)") + + +def build_get_docs(env: Settings, retriever: VectorStoreRetriever): + return RunnableParallel({"documents": retriever}) + + +@chain +def set_route(state: ChainState): + """ + Choose an approach to chatting based on the current state + """ + # Match keyword + route_match = re_keyword_pattern.search(state["query"].question) + if route_match: + selected = route_match.group()[1:] + elif len(state["query"].file_uuids) > 0: + selected = ChatRoute.chat_with_docs.value + else: + selected = ChatRoute.chat.value + log.info(f"Based on user query [{selected}] selected") + return {"route_name": selected} + + +def make_chat_prompt_from_messages_runnable( + system_prompt: str, + question_prompt: str, + input_token_budget: int, + tokeniser: Encoding, +): + system_prompt_message = [("system", system_prompt)] + prompts_budget = len(tokeniser.encode(system_prompt)) - len(tokeniser.encode(question_prompt)) + token_budget = input_token_budget - prompts_budget + + @chain + def chat_prompt_from_messages(state: ChainState): + """ + Create a ChatPrompTemplate as part of a chain using 'chat_history'. + Returns the PromptValue using values in the input_dict + """ + log.debug("Setting chat prompt") + chat_history_budget = token_budget - len(tokeniser.encode(state["query"].question)) + + if chat_history_budget <= 0: + raise QuestionLengthError + + truncated_history: list[dict[str, str]] = [] + for msg in state["query"].chat_history[::-1]: + chat_history_budget -= len(tokeniser.encode(msg["text"])) + if chat_history_budget <= 0: + break + else: + truncated_history.insert(0, msg) + + return ChatPromptTemplate.from_messages( + system_prompt_message + + [(msg["role"], msg["text"]) for msg in truncated_history] + + [("user", question_prompt)] + ).invoke(state["query"].dict() | state.get("prompt_args", {})) + + return chat_prompt_from_messages + + +@chain +def set_prompt_args(state: ChainState): + log.debug("Setting prompt args") + return { + "prompt_args": { + "formatted_documents": format_documents(state.get("documents") or []), + } + } + + +def build_llm_chain( + llm: BaseChatModel, tokeniser: Encoding, env: Settings, system_prompt: str, question_prompt: str +) -> Runnable: + return RunnableParallel( + { + "response": make_chat_prompt_from_messages_runnable( + system_prompt=system_prompt, + question_prompt=question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm.with_config(tags=["response"]) + | StrOutputParser(), + } + ) + + +def get_no_docs_available(env: Settings): + return RunnableLambda( + lambda _: { + "response": env.response_no_doc_available, + } + ) + + +def empty_node(state: ChainState): + log.info(f"Empty Node: {state}") + return None diff --git a/redbox-core/redbox/chains/query.py b/redbox-core/redbox/chains/query.py new file mode 100644 index 000000000..86d918d1d --- /dev/null +++ b/redbox-core/redbox/chains/query.py @@ -0,0 +1,285 @@ +import logging +from operator import itemgetter + +from langchain.prompts import PromptTemplate +from langchain.schema import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.retrievers import BaseRetriever +from langchain_core.runnables import Runnable, RunnableLambda, RunnablePassthrough, chain, RunnableConfig +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.vectorstores import VectorStoreRetriever +from tiktoken import Encoding + +from redbox.api.format import format_documents +from redbox.api.runnables import filter_by_elbow, make_chat_prompt_from_messages_runnable, resize_documents +from redbox.models import ChatRoute, Settings +from redbox.retriever.retrievers import AllElasticsearchRetriever +from redbox.models.errors import NoDocumentSelected + +# === Logging === + +log = logging.getLogger() + + +def build_chat_chain(llm: BaseChatModel, tokeniser: Encoding, env: Settings) -> Runnable: + return ( + make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.chat_system_prompt, + question_prompt=env.ai.chat_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | { + "response": StrOutputParser(), + "route_name": RunnableLambda(lambda _: ChatRoute.chat.value), + } + ) + + +def retrieve_chunks_at_summarisation_length(env: Settings, retriever: VectorStoreRetriever = None): + return ( + retriever + if retriever + else AllElasticsearchRetriever( + es_client=env.elasticsearch_client(), + index_name=f"{env.elastic_root_index}-chunk", + ) + | resize_documents(env.ai.summarisation_chunk_max_tokens) + ) + + +def build_chat_with_docs_chain( + llm: BaseChatModel, + all_chunks_retriever: BaseRetriever, + tokeniser: Encoding, + env: Settings, +) -> Runnable: + @chain + def map_operation(input_dict): + system_map_prompt = env.ai.map_system_prompt + prompt_template = PromptTemplate.from_template(env.ai.chat_map_question_prompt) + + formatted_map_question_prompt = prompt_template.format(question=input_dict["question"]) + + map_prompt = ChatPromptTemplate.from_messages( + [ + ("system", system_map_prompt), + ("human", formatted_map_question_prompt + env.ai.map_document_prompt), + ] + ) + + documents = input_dict["documents"] + + map_summaries = (map_prompt | llm | StrOutputParser()).batch( + documents, + config=RunnableConfig(max_concurrency=env.ai.summarisation_max_concurrency), + ) + + summaries = " ; ".join(map_summaries) + input_dict["summaries"] = summaries + return input_dict + + @chain + def chat_with_docs_route(input_dict: dict): + log.info("Documents: %s", input_dict["documents"]) + log.info("Length documents: %s", len(input_dict["documents"])) + if len(input_dict["documents"]) == 1: + return RunnablePassthrough.assign( + formatted_documents=(RunnablePassthrough() | itemgetter("documents") | format_documents) + ) | { + "response": make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.chat_with_docs_system_prompt, + question_prompt=env.ai.chat_with_docs_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser(), + "route_name": RunnableLambda(lambda _: ChatRoute.chat_with_docs.value), + } + + elif len(input_dict["documents"]) > 1: + return ( + map_operation + | RunnablePassthrough.assign( + formatted_documents=(RunnablePassthrough() | itemgetter("documents") | format_documents) + ) + | { + "response": make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.chat_with_docs_reduce_system_prompt, + question_prompt=env.ai.chat_with_docs_reduce_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser(), + "route_name": RunnableLambda(lambda _: ChatRoute.chat_with_docs.value), + } + ) + + else: + raise NoDocumentSelected + + return ( + RunnablePassthrough.assign(documents=retrieve_chunks_at_summarisation_length(env, all_chunks_retriever)) + | chat_with_docs_route + ) + + +def build_retrieval_chain( + llm: BaseChatModel, + retriever: VectorStoreRetriever, + tokeniser: Encoding, + env: Settings, +) -> Runnable: + return ( + RunnablePassthrough.assign(documents=retriever) + | RunnablePassthrough.assign( + formatted_documents=(RunnablePassthrough() | itemgetter("documents") | format_documents) + ) + | { + "response": make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.retrieval_system_prompt, + question_prompt=env.ai.retrieval_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser(), + "source_documents": itemgetter("documents"), + "route_name": RunnableLambda(lambda _: ChatRoute.search.value), + } + ) + + +def build_condense_retrieval_chain( + llm: BaseChatModel, + retriever: VectorStoreRetriever, + tokeniser: Encoding, + env: Settings, +) -> Runnable: + def route(input_dict: dict): + if len(input_dict["chat_history"]) > 0: + return RunnablePassthrough.assign( + question=make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.condense_system_prompt, + question_prompt=env.ai.condense_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser() + ) + else: + return RunnablePassthrough() + + return ( + RunnableLambda(route) + | RunnablePassthrough.assign(documents=retriever | filter_by_elbow(enabled=env.ai.elbow_filter_enabled)) + | RunnablePassthrough.assign( + formatted_documents=(RunnablePassthrough() | itemgetter("documents") | format_documents) + ) + | { + "response": make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.retrieval_system_prompt, + question_prompt=env.ai.retrieval_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser(), + "source_documents": itemgetter("documents"), + "route_name": RunnableLambda(lambda _: ChatRoute.search.value), + } + ) + + +def build_summary_chain( + llm: BaseChatModel, + all_chunks_retriever: VectorStoreRetriever, + tokeniser: Encoding, + env: Settings, +) -> Runnable: + def make_document_context(): + return ( + all_chunks_retriever + | resize_documents(env.ai.summarisation_chunk_max_tokens) + | RunnableLambda(lambda docs: [d.page_content for d in docs]) + ) + + # Stuff chain now missing the RunnabeLambda to format the chunks + stuff_chain = ( + make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.summarisation_system_prompt, + question_prompt=env.ai.summarisation_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | { + "response": StrOutputParser(), + "route_name": RunnableLambda(lambda _: ChatRoute.summarise.value), + } + ) + + @chain + def map_operation(input_dict): + system_map_prompt = env.ai.map_system_prompt + prompt_template = PromptTemplate.from_template(env.ai.chat_map_question_prompt) + + formatted_map_question_prompt = prompt_template.format(question=input_dict["question"]) + + map_prompt = ChatPromptTemplate.from_messages( + [ + ("system", system_map_prompt), + ("human", formatted_map_question_prompt + env.ai.map_document_prompt), + ] + ) + + documents = input_dict["documents"] + + map_summaries = (map_prompt | llm | StrOutputParser()).batch( + documents, + config=RunnableConfig(max_concurrency=env.ai.summarisation_max_concurrency), + ) + + summaries = " ; ".join(map_summaries) + input_dict["summaries"] = summaries + return input_dict + + map_reduce_chain = ( + map_operation + | make_chat_prompt_from_messages_runnable( + system_prompt=env.ai.reduce_system_prompt, + question_prompt=env.ai.reduce_question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | { + "response": StrOutputParser(), + "route_name": RunnableLambda(lambda _: ChatRoute.map_reduce_summarise.value), + } + ) + + @chain + def summarisation_route(input_dict): + if len(input_dict["documents"]) == 1: + return stuff_chain + + elif len(input_dict["documents"]) > 1: + return map_reduce_chain + + else: + raise NoDocumentSelected + + return RunnablePassthrough.assign(documents=make_document_context()) | summarisation_route + + +def build_static_response_chain(prompt_template, route_name) -> Runnable: + return RunnablePassthrough.assign( + response=(ChatPromptTemplate.from_template(prompt_template) | RunnableLambda(lambda p: p.messages[0].content)), + source_documents=RunnableLambda(lambda _: []), + route_name=RunnableLambda(lambda _: route_name.value), + ) diff --git a/redbox-core/redbox/graph/__init__.py b/redbox-core/redbox/graph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/redbox-core/redbox/graph/chat.py b/redbox-core/redbox/graph/chat.py new file mode 100644 index 000000000..e8fb970ff --- /dev/null +++ b/redbox-core/redbox/graph/chat.py @@ -0,0 +1,157 @@ +import logging +from langgraph.graph import StateGraph, START +from langgraph.constants import Send +from langgraph.graph.graph import CompiledGraph +from langchain_core.runnables import chain, RunnableLambda, Runnable +from langchain_core.documents import Document +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.output_parsers import StrOutputParser +from langchain_core.vectorstores import VectorStoreRetriever +from langchain_text_splitters import TextSplitter, TokenTextSplitter +from tiktoken import Encoding + +from redbox.api.format import format_documents +from redbox.models.chain import ChainState, ChatMapReduceState +from redbox.models.chat import ChatRoute +from redbox.chains.graph import ( + set_prompt_args, + build_llm_chain, + make_chat_prompt_from_messages_runnable, + build_get_docs, + get_no_docs_available, +) +from redbox.models.settings import Settings + +log = logging.getLogger() + + +@chain +def to_map_step(state: ChatMapReduceState): + """ + Map each doc in the state to an execution of the llm map step which will create an answer + per current document + """ + return [ + Send( + "llm_map", + ChatMapReduceState( + query=state["query"], documents=[doc], route_name=state["route_name"], prompt_args=state["prompt_args"] + ), + ) + for doc in state["documents"] + ] + + +def build_reduce_docs_step(splitter: TextSplitter): + return RunnableLambda( + lambda state: [ + Document(page_content=s) for s in splitter.split_text(format_documents(state["intermediate_docs"])) + ] + ) | RunnableLambda(lambda docs: {"documents": docs}) + + +def get_chat_graph(llm: BaseChatModel, tokeniser: Encoding, env: Settings, debug: bool = False) -> CompiledGraph: + app = StateGraph(ChainState) + app.set_entry_point("set_chat_prompt_args") + + app.add_node("set_chat_prompt_args", set_prompt_args) + app.add_edge("set_chat_prompt_args", "llm") + + app.add_node("llm", build_llm_chain(llm, tokeniser, env, env.ai.chat_system_prompt, env.ai.chat_question_prompt)) + + return app.compile(debug=debug) + + +@chain +def set_chat_method(state: ChainState): + """ + Choose an approach to chatting based on the current state + """ + log.debug("Selecting chat method") + number_of_docs = len(state["documents"]) + if number_of_docs == 0: + selected_tool = ChatRoute.chat + elif number_of_docs == 1: + selected_tool = ChatRoute.chat_with_docs + else: + selected_tool = ChatRoute.chat_with_docs_map_reduce + log.info(f"Selected: {selected_tool} for execution") + return {"route_name": selected_tool} + + +def build_llm_map_chain( + llm: BaseChatModel, tokeniser: Encoding, env: Settings, system_prompt: str, question_prompt: str +) -> Runnable: + return ( + make_chat_prompt_from_messages_runnable( + system_prompt=system_prompt, + question_prompt=question_prompt, + input_token_budget=env.ai.context_window_size - env.llm_max_tokens, + tokeniser=tokeniser, + ) + | llm + | StrOutputParser() + | RunnableLambda(lambda s: {"intermediate_docs": [Document(page_content=s)]}) + ) + + +def get_chat_with_docs_graph( + llm: BaseChatModel, + all_chunks_retriever: VectorStoreRetriever, + tokeniser: Encoding, + env: Settings, + debug: bool = False, +) -> CompiledGraph: + app = StateGraph(ChainState) + + app.add_node("get_chat_docs", build_get_docs(env, all_chunks_retriever)) + app.add_node("set_chat_prompt_args", set_prompt_args) + app.add_node("set_chat_method", set_chat_method) + + app.add_node("no_docs_available", get_no_docs_available(env)) + app.add_node( + "llm", + build_llm_chain( + llm, tokeniser, env, env.ai.chat_with_docs_system_prompt, env.ai.chat_with_docs_question_prompt + ), + ) + app.add_node(ChatRoute.chat_with_docs_map_reduce, get_chat_with_docs_map_reduce_graph(llm, tokeniser, env, debug)) + + app.add_edge(START, "get_chat_docs") + app.add_edge("get_chat_docs", "set_chat_prompt_args") + app.add_edge("set_chat_prompt_args", "set_chat_method") + app.add_conditional_edges( + "set_chat_method", + lambda state: state["route_name"], + { + ChatRoute.chat: "no_docs_available", + ChatRoute.chat_with_docs: "llm", + ChatRoute.chat_with_docs_map_reduce: ChatRoute.chat_with_docs_map_reduce, + }, + ) + app.add_edge(ChatRoute.chat_with_docs_map_reduce, "set_chat_prompt_args") + return app.compile(debug=debug) + + +def get_chat_with_docs_map_reduce_graph( + llm: BaseChatModel, tokeniser: Encoding, env: Settings, debug: bool = False +) -> CompiledGraph: + app = StateGraph(ChatMapReduceState) + + app.add_node( + "llm_map", build_llm_map_chain(llm, tokeniser, env, env.ai.map_system_prompt, env.ai.chat_map_question_prompt) + ) + app.add_node( + "reduce", + build_reduce_docs_step( + TokenTextSplitter( + model_name="gpt-4", + chunk_size=env.worker_ingest_largest_chunk_size, + chunk_overlap=env.worker_ingest_largest_chunk_overlap, + ) + ), + ) + + app.add_conditional_edges(START, to_map_step, then="reduce") + + return app.compile(debug=debug) diff --git a/redbox-core/redbox/graph/retriever/__init__.py b/redbox-core/redbox/graph/retriever/__init__.py new file mode 100644 index 000000000..6081452e3 --- /dev/null +++ b/redbox-core/redbox/graph/retriever/__init__.py @@ -0,0 +1,3 @@ +from .retrievers import AllElasticsearchRetriever, ParameterisedElasticsearchRetriever + +__all__ = ["ParameterisedElasticsearchRetriever", "AllElasticsearchRetriever"] diff --git a/redbox-core/redbox/graph/retriever/queries.py b/redbox-core/redbox/graph/retriever/queries.py new file mode 100644 index 000000000..98bcc206c --- /dev/null +++ b/redbox-core/redbox/graph/retriever/queries.py @@ -0,0 +1,110 @@ +from typing import Any, TypedDict +from uuid import UUID + +from langchain_core.embeddings.embeddings import Embeddings + +from redbox.models.chain import ChainState +from redbox.models.file import ChunkResolution + + +class ESParams(TypedDict): + size: int + num_candidates: int + match_boost: float + knn_boost: float + similarity_threshold: float + + +def make_query_filter(user_uuid: UUID, file_uuids: list[UUID], chunk_resolution: ChunkResolution | None) -> list[dict]: + query_filter: list[dict] = [ + { + "bool": { + "should": [ + {"term": {"creator_user_uuid.keyword": str(user_uuid)}}, + {"term": {"metadata.creator_user_uuid.keyword": str(user_uuid)}}, + ] + } + } + ] + + if len(file_uuids) != 0: + query_filter.append( + { + "bool": { + "should": [ + {"terms": {"parent_file_uuid.keyword": [str(uuid) for uuid in file_uuids]}}, + {"terms": {"metadata.parent_file_uuid.keyword": [str(uuid) for uuid in file_uuids]}}, + ] + } + } + ) + + if chunk_resolution: + query_filter.append( + { + "bool": { + "must": [ + {"term": {"metadata.chunk_resolution.keyword": str(chunk_resolution)}}, + ] + } + } + ) + return query_filter + + +def get_all( + chunk_resolution: ChunkResolution | None, + state: ChainState, +) -> dict[str, Any]: + """ + Returns a parameterised elastic query that will return everything it matches. + + As it's used in summarisation, it excludes embeddings. + """ + + query_filter = make_query_filter(state["query"].user_uuid, state["query"].file_uuids, chunk_resolution) + return { + "_source": {"excludes": ["*embedding"]}, + "query": {"bool": {"must": {"match_all": {}}, "filter": query_filter}}, + } + + +def get_some( + embedding_model: Embeddings, + params: ESParams, + embedding_field_name: str, + chunk_resolution: ChunkResolution | None, + state: ChainState, +) -> dict[str, Any]: + vector = embedding_model.embed_query(state["query"].question) + + query_filter = make_query_filter(state["query"].user_uuid, state["query"].file_uuids, chunk_resolution) + + return { + "size": params["size"], + "query": { + "bool": { + "should": [ + { + "match": { + "text": { + "query": state["query"].question, + "boost": params["match_boost"], + } + } + }, + { + "knn": { + "field": embedding_field_name, + "query_vector": vector, + "num_candidates": params["num_candidates"], + "filter": query_filter, + "boost": params["knn_boost"], + "similarity": params["similarity_threshold"], + } + }, + ], + "filter": query_filter, + } + }, + } diff --git a/redbox-core/redbox/graph/retriever/retrievers.py b/redbox-core/redbox/graph/retriever/retrievers.py new file mode 100644 index 000000000..18cba95b8 --- /dev/null +++ b/redbox-core/redbox/graph/retriever/retrievers.py @@ -0,0 +1,71 @@ +from functools import partial +from typing import Any + +from elasticsearch.helpers import scan +from langchain_core.callbacks import CallbackManagerForRetrieverRun +from langchain_core.documents import Document +from langchain_core.embeddings.embeddings import Embeddings +from langchain_elasticsearch.retrievers import ElasticsearchRetriever + +from redbox.models.file import ChunkResolution +from redbox.graph.retriever.queries import ESParams, get_all, get_some + + +def hit_to_doc(hit: dict[str, Any]) -> Document: + """ + Backwards compatibility for Chunks and Documents. + + Chunks has two metadata fields in top-level: index and parent_file_uuid. This moves them. + """ + source = hit["_source"] + c_meta = { + "index": source.get("index"), + "parent_file_uuid": source.get("parent_file_uuid"), + "score": hit["_score"], + } + return Document( + page_content=source["text"], metadata={k: v for k, v in c_meta.items() if v is not None} | source["metadata"] + ) + + +class ParameterisedElasticsearchRetriever(ElasticsearchRetriever): + params: ESParams + embedding_model: Embeddings + embedding_field_name: str = "embedding" + chunk_resolution: ChunkResolution = ChunkResolution.normal + + def __init__(self, **kwargs: Any) -> None: + # Hack to pass validation before overwrite + # Partly necessary due to how .with_config() interacts with a retriever + kwargs["body_func"] = get_some + kwargs["document_mapper"] = hit_to_doc + super().__init__(**kwargs) + self.body_func = partial( + get_some, self.embedding_model, self.params, self.embedding_field_name, self.chunk_resolution + ) + + +class AllElasticsearchRetriever(ElasticsearchRetriever): + chunk_resolution: ChunkResolution = ChunkResolution.largest + + def __init__(self, **kwargs: Any) -> None: + # Hack to pass validation before overwrite + # Partly necessary due to how .with_config() interacts with a retriever + kwargs["body_func"] = get_all + kwargs["document_mapper"] = hit_to_doc + super().__init__(**kwargs) + self.body_func = partial(get_all, self.chunk_resolution) + + def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> list[Document]: # noqa:ARG002 + if not self.es_client or not self.document_mapper: + msg = "faulty configuration" + raise ValueError(msg) # should not happen + + body = self.body_func(query) # type: ignore + + results = [ + self.document_mapper(hit) + for hit in scan(client=self.es_client, index=self.index_name, query=body, source=True) + ] + + return sorted(results, key=lambda result: result.metadata["index"]) diff --git a/redbox-core/redbox/graph/root.py b/redbox-core/redbox/graph/root.py new file mode 100644 index 000000000..2c422a32c --- /dev/null +++ b/redbox-core/redbox/graph/root.py @@ -0,0 +1,97 @@ +import typing +import os +import sys +import logging +from langgraph.graph import StateGraph, END +from langgraph.graph.graph import CompiledGraph +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.vectorstores import VectorStoreRetriever +from tiktoken import Encoding +import asyncio + +from redbox.chains.graph import set_route +from redbox.graph.search import get_search_graph +from redbox.models.chain import ChainInput, ChainState +from redbox.models.chat import ChatRoute +from redbox.models.settings import Settings +from redbox.chains.components import get_all_chunks_retriever, get_parameterised_retriever, get_chat_llm, get_tokeniser +from redbox.graph.chat import get_chat_graph, get_chat_with_docs_graph + + +def get_redbox_graph( + llm: BaseChatModel = None, + all_chunks_retriever: VectorStoreRetriever = None, + parameterised_retriever: VectorStoreRetriever = None, + tokeniser: Encoding = None, + env: Settings = None, + debug: bool = False, +): + _env = env or Settings() + _all_chunks_retriever = all_chunks_retriever or get_all_chunks_retriever(_env) + _parameterised_retriever = parameterised_retriever or get_parameterised_retriever(_env) + _llm = llm or get_chat_llm(_env) + _tokeniser = tokeniser or get_tokeniser() + + app = StateGraph(ChainState) + app.set_entry_point("set_route") + + app.add_node("set_route", set_route) + app.add_conditional_edges("set_route", lambda s: s["route_name"]) + + app.add_node(ChatRoute.search, get_search_graph(_llm, _parameterised_retriever, _tokeniser, _env, debug)) + app.add_edge(ChatRoute.search, END) + + app.add_node(ChatRoute.chat, get_chat_graph(_llm, _tokeniser, _env, debug)) + app.add_edge(ChatRoute.chat, END) + + app.add_node( + ChatRoute.chat_with_docs, get_chat_with_docs_graph(_llm, _all_chunks_retriever, _tokeniser, _env, debug) + ) + app.add_edge(ChatRoute.chat_with_docs, END) + + return app.compile(debug=debug) + + +async def run_redbox( + input: ChainState, + app: CompiledGraph, + response_tokens_callback: typing.Callable[[str], None] = lambda _: _, +) -> ChainState: + final_state = None + async for event in app.astream_events(input, version="v2"): + kind = event["event"] + tags = event.get("tags", []) + if kind == "on_chat_model_stream" and "response" in tags: + data = event["data"] + if data["chunk"].content: + response_tokens_callback(data["chunk"].content) + if kind == "on_chain_end" and event["name"] == "LangGraph": + final_state = ChainState(**event["data"]["output"]) + return final_state + + +if __name__ == "__main__": + import os + + logging.basicConfig(stream=sys.stdout, level=os.environ.get("LOG_LEVEL", "INFO")) + + app = get_redbox_graph() + response = asyncio.run( + run_redbox( + ChainState( + query=ChainInput( + question="What are Labour's five missions?", + # file_uuids=[], + file_uuids=["68e5d196-636e-4847-95ad-6c40ba20e390"], + user_uuid="a93a8f40-f261-4f12-869a-2cea3f3f0d71", + chat_history=[], + ) + ), + app, + ) + ) + print() + print(f"{len(response["documents"])} source documents") + print(f"Used {response["route_name"]}") + print() + print(response["response"]) diff --git a/redbox-core/redbox/graph/search.py b/redbox-core/redbox/graph/search.py new file mode 100644 index 000000000..f18c4f5f2 --- /dev/null +++ b/redbox-core/redbox/graph/search.py @@ -0,0 +1,28 @@ +from langgraph.graph import StateGraph, START +from langgraph.graph.graph import CompiledGraph +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.vectorstores import VectorStoreRetriever +from tiktoken import Encoding + +from redbox.chains.graph import build_get_docs, build_llm_chain, set_prompt_args +from redbox.models.chain import ChainState +from redbox.models.settings import Settings + + +def get_search_graph( + llm: BaseChatModel, retriever: VectorStoreRetriever, tokeniser: Encoding, env: Settings, debug: bool = False +) -> CompiledGraph: + app = StateGraph(ChainState) + + app.add_node("get_docs", build_get_docs(env, retriever)) + app.add_node("set_prompt_args", set_prompt_args) + + app.add_node( + "llm", build_llm_chain(llm, tokeniser, env, env.ai.retrieval_system_prompt, env.ai.retrieval_question_prompt) + ) + + app.add_edge(START, "get_docs") + app.add_edge("get_docs", "set_prompt_args") + app.add_edge("set_prompt_args", "llm") + + return app.compile(debug=debug) diff --git a/redbox-core/redbox/models/chain.py b/redbox-core/redbox/models/chain.py index eb2a27e9d..6897d673f 100644 --- a/redbox-core/redbox/models/chain.py +++ b/redbox-core/redbox/models/chain.py @@ -5,9 +5,12 @@ used in conjunction with langchain this is the tidiest boxing of pydantic v1 we can do """ -from typing import TypedDict, Literal +from typing import TypedDict, Literal, Annotated from uuid import UUID +from operator import add + from langchain_core.pydantic_v1 import BaseModel, Field +from langchain_core.documents import Document class ChainChatMessage(TypedDict): @@ -20,3 +23,15 @@ class ChainInput(BaseModel): file_uuids: list[UUID] = Field(description="List of files to process") user_uuid: UUID = Field(description="User the chain in executing for") chat_history: list[ChainChatMessage] = Field(description="All previous messages in chat (excluding question)") + + +class ChainState(TypedDict): + query: ChainInput + documents: list[Document] + response: str | None + route_name: str | None + prompt_args: dict[str, str] + + +class ChatMapReduceState(ChainState): + intermediate_docs: Annotated[list[Document], add] diff --git a/redbox-core/redbox/models/settings.py b/redbox-core/redbox/models/settings.py index daf9c6763..559a07559 100644 --- a/redbox-core/redbox/models/settings.py +++ b/redbox-core/redbox/models/settings.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict -logging.basicConfig(level=logging.INFO) log = logging.getLogger() @@ -87,11 +86,10 @@ SUMMARISATION_QUESTION_PROMPT = "Question: {question}. \n\n Documents: \n\n {documents} \n\n Answer: " -MAP_QUESTION_PROMPT = "Question: {question}. " +CHAT_MAP_QUESTION_PROMPT = "Question: {question}. \n Documents: \n {formatted_documents} \n\n Answer: " -MAP_DOCUMENT_PROMPT = "\n\n Documents: \n\n {documents} \n\n Answer: " -REDUCE_QUESTION_PROMPT = "Question: {question}. \n\n Documents: \n\n {summaries} \n\n Answer: " +REDUCE_QUESTION_PROMPT = "Question: {question}. \n\n Documents: \n\n {formatted_documents} \n\n Answer: " CONDENSE_QUESTION_PROMPT = "{question}\n=========\n Standalone question: " @@ -121,8 +119,7 @@ class AISettings(BaseModel): summarisation_question_prompt: str = SUMMARISATION_QUESTION_PROMPT map_max_concurrency: int = 128 map_system_prompt: str = MAP_SYSTEM_PROMPT - map_question_prompt: str = MAP_QUESTION_PROMPT - map_document_prompt: str = MAP_DOCUMENT_PROMPT + chat_map_question_prompt: str = CHAT_MAP_QUESTION_PROMPT reduce_system_prompt: str = REDUCE_SYSTEM_PROMPT reduce_question_prompt: str = REDUCE_QUESTION_PROMPT @@ -172,7 +169,7 @@ class Settings(BaseSettings): azure_embedding_model: str = "text-embedding-3-large" llm_max_tokens: int = 1024 - embedding_backend: Literal["azure", "openai"] = "azure" + embedding_backend: Literal["azure", "openai", "fake"] = "azure" embedding_max_retries: int = 10 embedding_retry_min_seconds: int = 10 embedding_retry_max_seconds: int = 120 @@ -217,6 +214,8 @@ class Settings(BaseSettings): worker_ingest_largest_chunk_size: int = 96000 worker_ingest_largest_chunk_overlap: int = 0 + response_no_doc_available: str = "No available data for selected files. They may need to be removed and added again" + response_max_content_exceeded: str = "Max content exceeded. Try smaller or fewer documents" redis_host: str = "redis" redis_port: int = 6379 diff --git a/redbox-core/tests/graph/__init__.py b/redbox-core/tests/graph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/redbox-core/tests/graph/data.py b/redbox-core/tests/graph/data.py new file mode 100644 index 000000000..eacb333f5 --- /dev/null +++ b/redbox-core/tests/graph/data.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +import datetime +from uuid import UUID + +from langchain_core.documents import Document + +from redbox.models.chain import ChainInput +from redbox.models.chat import ChatRoute +from redbox.models.file import ChunkMetadata, ChunkResolution + + +def generate_docs( + parent_file_uuid: UUID, + creator_user_uuid: UUID, + file_name: str = "test_data.pdf", + page_numbers: list[int] = [1, 2, 3, 4], + total_tokens=6000, + number_of_docs: int = 10, + chunk_resolution=ChunkResolution.normal, +): + for i in range(number_of_docs): + yield Document( + page_content=f"Document {i} text", + metadata=ChunkMetadata( + parent_file_uuid=parent_file_uuid, + creator_user_uuid=creator_user_uuid, + index=i, + file_name=file_name, + page_number=page_numbers[int(i / number_of_docs) * len(page_numbers)], + created_datetime=datetime.datetime.now(datetime.UTC), + token_count=int(total_tokens / number_of_docs), + chunk_resolution=chunk_resolution, + ).model_dump(), + ) + + +@dataclass +class TestData: + number_of_docs: int + tokens_in_all_docs: int + expected_llm_response: list[str] + expected_route: ChatRoute + + +class RedboxChatTestCase: + def __init__( + self, + query: ChainInput, + test_data: TestData, + docs_user_uuid_override: UUID = None, + docs_file_uuids_override: list[UUID] = None, + ): + # Use separate file_uuids if specified else match the query + all_file_uuids = docs_file_uuids_override if docs_file_uuids_override else [id for id in query.file_uuids] + # Use separate user uuid if specific else match the query + docs_user_uuid = docs_user_uuid_override if docs_user_uuid_override else query.user_uuid + file_generators = [ + generate_docs( + parent_file_uuid=file_uuid, + creator_user_uuid=docs_user_uuid, + total_tokens=test_data.tokens_in_all_docs, + number_of_docs=test_data.number_of_docs, + chunk_resolution=ChunkResolution.largest, + ) + for file_uuid in all_file_uuids + ] + self.query = query + self.docs = [doc for generator in file_generators for doc in generator] + self.llm_response = test_data.expected_llm_response + self.expected_route = test_data.expected_route + + +def generate_test_cases(query: ChainInput, test_data: list[TestData]): + return [RedboxChatTestCase(query=query, test_data=data) for data in test_data] diff --git a/redbox-core/tests/graph/test_redbox_graph.py b/redbox-core/tests/graph/test_redbox_graph.py new file mode 100644 index 000000000..67383e35e --- /dev/null +++ b/redbox-core/tests/graph/test_redbox_graph.py @@ -0,0 +1,221 @@ +from uuid import uuid4 +import pytest +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.runnables import RunnableLambda +import tiktoken + +from redbox.models.chain import ChainInput, ChainState +from redbox.graph.root import get_redbox_graph, run_redbox +from redbox.models.chat import ChatRoute +from redbox.models.settings import Settings +from tests.graph.data import RedboxChatTestCase, TestData, generate_test_cases + + +LANGGRAPH_DEBUG = False + +test_env = Settings() + + +@pytest.fixture(scope="session") +def env(): + return Settings() + + +@pytest.fixture(scope="session") +def tokeniser(): + return tiktoken.get_encoding("cl100k_base") + + +def all_chunks_retriever(docs): + def mock_retrieve(query): + return docs + + return RunnableLambda(mock_retrieve) + + +def parameterised_retriever(docs): + def mock_retrieve(query): + return docs + + return RunnableLambda(mock_retrieve) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("test_case"), + [ + test_case + for generated_cases in [ + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(0, 0, ["Testing Response 1"], ChatRoute.chat), + TestData(1, 100, ["Testing Response 1"], ChatRoute.chat), + TestData(10, 1200, ["Testing Response 1"], ChatRoute.chat), + ], + ), + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(1, 10000, ["Testing Response 1"], ChatRoute.chat_with_docs), + TestData(1, 80000, ["Testing Response 1"], ChatRoute.chat_with_docs), + TestData(1, 120000, ["Testing Response 1"], ChatRoute.chat_with_docs), + ], + ), + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(1, 10000, ["Testing Response 1"], ChatRoute.chat_with_docs), + TestData(1, 80000, ["Testing Response 1"], ChatRoute.chat_with_docs), + TestData(1, 120000, ["Testing Response 1"], ChatRoute.chat_with_docs), + ], + ), + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData( + 2, 200_000, ["Intermediate document"] * 2 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + TestData( + 5, 80000, ["Intermediate document"] * 5 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + TestData( + 10, 100_000, ["Intermediate document"] * 10 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + ], + ), + ] + for test_case in generated_cases + ], +) +async def test_chat(test_case: RedboxChatTestCase, env, tokeniser): + app = get_redbox_graph( + llm=GenericFakeChatModel(messages=iter(test_case.llm_response)), + all_chunks_retriever=all_chunks_retriever(test_case.docs), + parameterised_retriever=parameterised_retriever(test_case.docs), + tokeniser=tokeniser, + env=env, + debug=LANGGRAPH_DEBUG, + ) + response = await run_redbox( + input=ChainState(query=test_case.query), + app=app, + ) + final_state = ChainState(response) + assert ( + final_state["response"] == test_case.llm_response[-1] + ), f"Expected LLM response: '{test_case.llm_response[-1]}'. Received '{final_state["response"]}'" + assert ( + final_state["route_name"] == test_case.expected_route + ), f"Expected Route: '{ test_case.expected_route}'. Received '{final_state["route_name"]}'" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("test_case"), + [ + test_case + for generated_cases in [ + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(0, 0, ["The cake is a lie"], ChatRoute.chat), + ], + ), + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(1, 10000, ["The cake is a lie"], ChatRoute.chat_with_docs), + TestData(5, 10000, ["map_reduce_result"] * 5 + ["The cake is a lie"], ChatRoute.chat_with_docs), + ], + ), + generate_test_cases( + query=ChainInput( + question="@search What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[] + ), + test_data=[ + TestData(1, 10000, ["The cake is a lie"], ChatRoute.search), + TestData(5, 10000, ["The cake is a lie"], ChatRoute.search), + ], + ), + ] + for test_case in generated_cases + ], +) +async def test_streaming(test_case: RedboxChatTestCase, env, tokeniser): + app = get_redbox_graph( + llm=GenericFakeChatModel(messages=iter(test_case.llm_response)), + all_chunks_retriever=all_chunks_retriever(test_case.docs), + parameterised_retriever=parameterised_retriever(test_case.docs), + tokeniser=tokeniser, + env=env, + debug=LANGGRAPH_DEBUG, + ) + + token_events = [] + + def streaming_response_handler(tokens: str): + token_events.append(tokens) + + response = await run_redbox( + input=ChainState(query=test_case.query), app=app, response_tokens_callback=streaming_response_handler + ) + final_state = ChainState(response) + print(token_events) + assert len(token_events) > 1, f"Expected tokens as a stream. Received: {token_events}" + llm_response = "".join(token_events) + assert ( + final_state["response"] == llm_response + ), f"Expected LLM response: '{llm_response}'. Received '{final_state["response"]}'" + assert ( + final_state["route_name"] == test_case.expected_route + ), f"Expected Route: '{ test_case.expected_route}'. Received '{final_state["route_name"]}'" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("test_case"), + [ + test_case + for generated_cases in [ + generate_test_cases( + query=ChainInput(question="@search What is AI?", file_uuids=[], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData(0, 0, [test_env.response_no_doc_available], ChatRoute.search), + ], + ), + generate_test_cases( + query=ChainInput(question="What is AI?", file_uuids=[uuid4()], user_uuid=uuid4(), chat_history=[]), + test_data=[ + TestData( + 2, 200_000, ["Intermediate document"] * 2 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + TestData( + 5, 80000, ["Intermediate document"] * 5 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + TestData( + 10, 100_000, ["Intermediate document"] * 10 + ["Testing Response 1"], ChatRoute.chat_with_docs + ), + ], + ), + ] + for test_case in generated_cases + ], +) +async def test_search(test_case: RedboxChatTestCase, env, tokeniser): + app = get_redbox_graph( + llm=GenericFakeChatModel(messages=iter(test_case.llm_response)), + all_chunks_retriever=all_chunks_retriever(test_case.docs), + parameterised_retriever=parameterised_retriever(test_case.docs), + tokeniser=tokeniser, + env=env, + debug=LANGGRAPH_DEBUG, + ) + response = await run_redbox(input=ChainState(query=test_case.query), app=app) + final_state = ChainState(response) + assert ( + final_state["response"] == test_case.llm_response[-1] + ), f"Expected LLM response: '{test_case.llm_response[-1]}'. Received '{final_state["response"]}'" + assert ( + final_state["route_name"] == test_case.expected_route + ), f"Expected Route: '{ test_case.expected_route}'. Received '{final_state["route_name"]}'"