diff --git a/Pipfile b/Pipfile index d06d68e2..43e7a592 100644 --- a/Pipfile +++ b/Pipfile @@ -34,6 +34,7 @@ readabilipy = "*" langchain-perplexity = "*" pyuploadcare = "*" pillow = "*" +resvg-py = "*" google-genai = "*" python-multipart = "*" langchain-xai = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 11cebcbd..496a1281 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "55e1c7b5505040c91fdf8b9ebce0c888da09f259fec7b07d4f999801e8e72467" + "sha256": "65161bd33ee8d441e1456c624850d431991fc33da7a84709dfdf5e8742e967a6" }, "pipfile-spec": 6, "requires": { @@ -187,11 +187,11 @@ }, "anthropic": { "hashes": [ - "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", - "sha256:9de947b737f39452f68aa520f1c2239d44119c9b73b0fb6d4e6ca80f00279ee6" + "sha256:107ebf954415382fdcea6a94f9cf334a53199ad64794403590dc55366cefcc28", + "sha256:62205edec42f5877df63d58be8e9443843d3e032215836e228fba1f59514a433" ], "markers": "python_version >= '3.9'", - "version": "==0.96.0" + "version": "==0.98.1" }, "anyio": { "hashes": [ @@ -219,11 +219,11 @@ }, "certifi": { "hashes": [ - "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", - "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" + "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", + "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" ], "markers": "python_version >= '3.7'", - "version": "==2026.2.25" + "version": "==2026.4.22" }, "cffi": { "hashes": [ @@ -452,66 +452,66 @@ }, "click": { "hashes": [ - "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", - "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" + "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", + "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613" ], "markers": "python_version >= '3.10'", - "version": "==8.3.2" + "version": "==8.3.3" }, "cryptography": { "hashes": [ - "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", - "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", - "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", - "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", - "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", - "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", - "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", - "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", - "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", - "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", - "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", - "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", - "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", - "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", - "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", - "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", - "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", - "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", - "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", - "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", - "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", - "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", - "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", - "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", - "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", - "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", - "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", - "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", - "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", - "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", - "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", - "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", - "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", - "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", - "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", - "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", - "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", - "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", - "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", - "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", - "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", - "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", - "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", - "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", - "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", - "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", - "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", - "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", - "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" - ], - "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.7" + "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", + "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", + "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", + "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", + "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", + "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", + "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", + "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", + "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", + "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", + "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", + "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", + "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", + "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", + "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", + "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", + "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", + "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", + "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", + "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", + "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", + "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", + "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", + "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", + "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", + "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", + "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", + "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", + "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", + "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", + "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", + "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", + "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", + "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", + "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", + "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", + "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", + "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", + "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", + "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", + "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", + "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", + "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", + "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", + "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", + "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", + "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", + "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", + "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b" + ], + "markers": "python_version >= '3.9' and python_full_version not in '3.9.0, 3.9.1'", + "version": "==48.0.0" }, "dataclasses-json": { "hashes": [ @@ -564,12 +564,12 @@ }, "fastapi": { "hashes": [ - "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", - "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e" + "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", + "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.136.0" + "version": "==0.136.1" }, "filetype": { "hashes": [ @@ -719,20 +719,20 @@ "requests" ], "hashes": [ - "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", - "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5" + "sha256:04382175e28b94f49694977f0a792688b59a668def1499e9d8de996dc9ce5b15", + "sha256:f35eafb191195328e8ce10a7883970877e7aeb49c2bfaa54aa0e394316d353d0" ], "markers": "python_version >= '3.8'", - "version": "==2.49.2" + "version": "==2.50.0" }, "google-genai": { "hashes": [ - "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", - "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15" + "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", + "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==1.73.1" + "version": "==1.75.0" }, "googleapis-common-protos": { "hashes": [ @@ -742,71 +742,6 @@ "markers": "python_version >= '3.9'", "version": "==1.74.0" }, - "greenlet": { - "hashes": [ - "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", - "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", - "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", - "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", - "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", - "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", - "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", - "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", - "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", - "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", - "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", - "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", - "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", - "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", - "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31", - "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", - "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", - "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", - "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", - "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398", - "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf", - "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", - "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", - "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", - "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82", - "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", - "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", - "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", - "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", - "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", - "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", - "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", - "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", - "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", - "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", - "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", - "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", - "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", - "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", - "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", - "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab", - "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", - "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", - "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", - "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508", - "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b", - "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", - "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", - "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", - "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6", - "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb", - "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", - "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", - "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", - "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", - "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", - "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", - "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", - "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf" - ], - "markers": "python_version >= '3.10'", - "version": "==3.4.0" - }, "grpcio": { "hashes": [ "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", @@ -876,12 +811,12 @@ }, "gunicorn": { "hashes": [ - "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", - "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889" + "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", + "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==25.3.0" + "version": "==26.0.0" }, "h11": { "hashes": [ @@ -927,11 +862,11 @@ }, "idna": { "hashes": [ - "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", - "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254" + "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", + "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" ], "markers": "python_version >= '3.8'", - "version": "==3.12" + "version": "==3.13" }, "importlib-metadata": { "hashes": [ @@ -1083,29 +1018,29 @@ }, "langchain": { "hashes": [ - "sha256:1717b6719daefae90b2728314a5e2a117ff916291e2862595b6c3d6fba33d652", - "sha256:e349db349cb3e9550c4044077cf90a1717691756cc236438404b23500e615874" + "sha256:c30b578c0eebbde8bec9247dbbbae1a791128557b99b65c8be1e007040975d09", + "sha256:ff881cdfbe90e0b6afac42eea7999657c282cc73db059c910d803f4e9f8ff305" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.2.15" + "version": "==1.2.17" }, "langchain-anthropic": { "hashes": [ - "sha256:5a48afbb2b1bad9c46badaccc8e23b0dd7ae07b7583f76bac21ddb3dac831efd", - "sha256:e17d027091438620e35ff2f06aefdfd63c8dcdf6abc606009ddfe3c764f2bc2e" + "sha256:65466e0f2f95909a009708f2958e917dfdbfab79c612b4484a30866a85e1f291", + "sha256:f8a2442463c0629b1b3110eaeaa56fdbdc87df2a802f8c7f5ecf611eb4874ec8" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.4.1" + "version": "==1.4.3" }, "langchain-classic": { "hashes": [ - "sha256:03842b2f9681f0f56978d94c1dc155c9316ce1575db88584316dcd9087823ede", - "sha256:b55d0925bc66cf2efa0b65d71c514c42cf9e4df798b9e976c063b3ecea8cf272" + "sha256:33c2c0d8483ce7523e81b7e9107459a666497b39ad1018d7e635999b790e52c6", + "sha256:824755c79c029a1c1ad389a6e2a75fd8eecdb87d427197d8777570ec55512022" ], "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.0.4" + "version": "==1.0.5" }, "langchain-community": { "hashes": [ @@ -1118,12 +1053,12 @@ }, "langchain-core": { "hashes": [ - "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", - "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced" + "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", + "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.3.0" + "version": "==1.3.2" }, "langchain-google-genai": { "hashes": [ @@ -1136,21 +1071,29 @@ }, "langchain-openai": { "hashes": [ - "sha256:306f1c7f66f47bd39655e645fd1f9445d65473465a59347fa7d716596e28dfe0", - "sha256:648becb4fb86d24d56b9292e01878a05cca932bb475909bb8fb85b8c82dd1a02" + "sha256:a80732185030d4f453dda6c25feef46f645f665423fdffe38ae3edf1ac3c6c4d", + "sha256:ee4480b787706361b7125fad46930589a624df87aa158c6986ef1fad10d10675" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.1.16" + "version": "==1.2.1" }, "langchain-perplexity": { "hashes": [ - "sha256:74165561403869aa4dd01b215ecb051578a9d794e843e8e7cc13999c68ff69b5", - "sha256:c8e655d0e0dfebfbaa8e028ecd0695f6bb73d030930ac2f07b4bb35fedc178dd" + "sha256:2992ed788dbff13cf8b80d9fb265acda20d5c3956c712ea8da47733b7b5ede3e", + "sha256:87a031bbf47bd117ae9f760cf8276ae6c296cf2000147ae97b27a80b9cc92bd8" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.1.0" + "version": "==1.2.0" + }, + "langchain-protocol": { + "hashes": [ + "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", + "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade" + ], + "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", + "version": "==0.0.15" }, "langchain-text-splitters": { "hashes": [ @@ -1171,27 +1114,27 @@ }, "langgraph": { "hashes": [ - "sha256:7db13ceecde4ea643df6c097dcc9e534895dcd9fcc6500eeff2f2cde0fab16b2", - "sha256:bc5a49d5a5e71fda1f9c53c06c62f4caec9a95545b739d130a58b6ab3269e274" + "sha256:3115beb58203283c98d8752a90c034f3432177d2979a1fe205f76e5f1b744500", + "sha256:8a4f163f72f4401648d0c11b48ee906947d938ba8cf1f474540fe591534f0d17" ], "markers": "python_version >= '3.10'", - "version": "==1.1.9" + "version": "==1.1.10" }, "langgraph-checkpoint": { "hashes": [ - "sha256:4f6f99cba8e272deabf81b2d8cdc96582af07a57a6ad591cdf216bb310497039", - "sha256:59b0f29216128a629c58dd07c98aa004f82f51805d5573126ffb419b753ff253" + "sha256:a7b5e2ca18fb79b55edf19396d4ee446f8a53dcb7a4ec62ce6f1c7e00bb5af7f", + "sha256:b91b765712a2311a5b198760f714b7ab9b376d01c047ed78d9b9a3e80df802a3" ], "markers": "python_version >= '3.10'", - "version": "==4.0.2" + "version": "==4.0.3" }, "langgraph-prebuilt": { "hashes": [ - "sha256:5a6fc513f8907074563b6218ff991c4ed9db19ac63101314919686e8029ddb07", - "sha256:e3baa1977d819982e690a357ba5bb77ccc1d4d8d4a029c48e502a3b6d171185f" + "sha256:7055e9fad41fbd3593800aed0aea0a6e974b17f33ed51b80d3d3a031212dd7c0", + "sha256:ad219782a80e1718e7e7794de49e0ae307111d45cbcffab9a52725a66a609456" ], "markers": "python_version >= '3.10'", - "version": "==1.0.10" + "version": "==1.0.13" }, "langgraph-sdk": { "hashes": [ @@ -1203,11 +1146,11 @@ }, "langsmith": { "hashes": [ - "sha256:5b535b991d52d3b664ebb8dc6f95afcf8d0acb42e062ac45a54a6a4820139f20", - "sha256:fa2d81ad6e8374a81fda9291894f6fcae714e55fbf11a0b07578e3cd4b1ea384" + "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", + "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3" ], "markers": "python_version >= '3.10'", - "version": "==0.7.33" + "version": "==0.8.0" }, "lxml": { "hashes": [ @@ -1351,11 +1294,11 @@ }, "mako": { "hashes": [ - "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", - "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77" + "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", + "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a" ], "markers": "python_version >= '3.8'", - "version": "==1.3.11" + "version": "==1.3.12" }, "markupsafe": { "hashes": [ @@ -1700,35 +1643,35 @@ }, "openai": { "hashes": [ - "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", - "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0" + "sha256:828b4efcbb126352c2b5eb97d33ae890c92a71ab72511aefc1b7fe64aeccb07b", + "sha256:c996a71b1a210f3569844572ad4c609307e978515fb76877cf449b72596e549e" ], "markers": "python_version >= '3.9'", - "version": "==2.32.0" + "version": "==2.34.0" }, "opentelemetry-api": { "hashes": [ - "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", - "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09" + "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", + "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f" ], "markers": "python_version >= '3.9'", - "version": "==1.41.0" + "version": "==1.41.1" }, "opentelemetry-sdk": { "hashes": [ - "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", - "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd" + "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", + "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d" ], "markers": "python_version >= '3.9'", - "version": "==1.41.0" + "version": "==1.41.1" }, "opentelemetry-semantic-conventions": { "hashes": [ - "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", - "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097" + "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", + "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c" ], "markers": "python_version >= '3.9'", - "version": "==0.62b0" + "version": "==0.62b1" }, "orjson": { "hashes": [ @@ -1874,6 +1817,14 @@ "markers": "python_version >= '3.8'", "version": "==25.0" }, + "perplexityai": { + "hashes": [ + "sha256:b03503498591d06c4d50b666f7f7469875d3586f664c29416aae9012ae7a64d1", + "sha256:e5017d245fd8966cf79657edc03a93078d867708542b491b38152618f91e369b" + ], + "markers": "python_version >= '3.9'", + "version": "==0.32.1" + }, "pillow": { "hashes": [ "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", @@ -2370,19 +2321,19 @@ }, "pymupdf": { "hashes": [ - "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", - "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", - "sha256:6cebfbbdfd219ebdebf4d8e3914624b2e3d3a844c43f4f76935822dd9b13cc12", - "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", - "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", - "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", - "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", - "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", - "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335" + "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", + "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", + "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", + "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", + "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", + "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", + "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", + "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", + "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==1.27.2.2" + "version": "==1.27.2.3" }, "python-dateutil": { "hashes": [ @@ -2414,12 +2365,12 @@ }, "python-multipart": { "hashes": [ - "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", - "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185" + "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", + "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.0.26" + "version": "==0.0.27" }, "pyuploadcare": { "hashes": [ @@ -2664,6 +2615,119 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.0.0" }, + "resvg-py": { + "hashes": [ + "sha256:004b39716835042bf1b9c9908e8a5c07065c1998ef739dd65e8c259d619c594e", + "sha256:01bf85cee2d8a6569b43b74c767e33edb2e1e91585f3224e7a788437df0ebb92", + "sha256:033d52a565ac19939b92cd6e4e8507ac8430bb1b616a33a5957a74d383df4e96", + "sha256:07c7ef3eff4ad7c6ed1c73f3a88899e33f555b953c36e9106b3aa51b47197072", + "sha256:081ea2dcff269cb6919d2c6d9bb5f236b57a7b320558a0daaec6a3a5eafb5109", + "sha256:0d9f57794dde2757ab78e7eaa870a0053f139b95b933a61755af251886cefca9", + "sha256:1237fb10ff5f4bd37fa143e29be5b4717d478fe9c4b2cad6c6df8167f70fa8d5", + "sha256:12c90476f10de277c8d304ec8abd8370a10164f777a313f63c6a253609094d71", + "sha256:1468360377c1632552b81aeb51f652cd4621fe4a8af3b56f9fe26404e90acaa7", + "sha256:1791c79a3b528949d47de3481a654cdafff23a0d9dd0262ef6857e1b5696793a", + "sha256:17b99316937059fe17738d65473a4a99c8fa7ebc4dc0cfcf77b046b9ab74e706", + "sha256:1816ea93623667b2b6b2576c91eac5320ee626948ae974f5b8da30f3f8897fe3", + "sha256:1861c40d4aa1ab6a1ebc5d0ba01e75d15591542de4cc5ce0ede6f8d87ccdfc28", + "sha256:1948ccdfd6533ff7f16b7f6736d9578c3fdef4c4dc48e6b9f074629c378a09c1", + "sha256:19ecf7f02a723fa57ffbe61d2a8084247c6e809c142949f1c70b76163ead715a", + "sha256:2097dbbf6d4e49dc3f177cfe24d4eec74f9960694ee5142ca353ab7f0a6dd5d1", + "sha256:26f0726d26bf37aa834147bb8fe546e95483b72cd8c4076eef6b9114309bcc1b", + "sha256:26f318beb1b5f7b22c09856e10dc3f9e766b5090e09c5807998a8560dc478a49", + "sha256:2bee42d88f89b194897e1dd2845c1588ffabbdeddd7440b929de318cd84062ee", + "sha256:2e42527c311f293bf1d66f8f2710a9af7d33323b6f294deaa2db0450f37efc41", + "sha256:2ebe00ee61dfd884d7336bede836184c0dce3372d22eb26da91eebb894af7e6a", + "sha256:2fda98e05653c9bcd1468987b7d266e0f42fae31e337a157898b1b1745bb7066", + "sha256:3b52242e2726a335e0f9bf50a08c1d8557ac4b2dbe29cda75ea03471ebec8967", + "sha256:3e7aabda988380b81a3401863a3c51ea8dc0e2d33e162c8143c1720ebb306597", + "sha256:4135d2f73aa8e2964d86dcaf7f6c163e99266643224db482d1e1bb4a2e2934b2", + "sha256:43c487fe0703b1538571208837790c9a973c28393b66123d256e30cc9cb1746c", + "sha256:453b23c2cd797fe2fae9cba054e1960294132a9ed3a70e8790ae0d06afcaaa8b", + "sha256:485456303402d3ab562392967f89681702dcfe1d0ce13690602bb945c8b66939", + "sha256:4c2596e8db071841c03a532915ca15b0cb12a9d36bc7a816b667a232d2c6a28b", + "sha256:4d455955ca83b84f3d56fcd8c589782a25098e3b9bd6e54ac702b87e685d9f38", + "sha256:5014f10fc9540c40adcabfeaf16b65e0dcfe9bf933c4a196dac7378170b394ef", + "sha256:5219ff074e58a610c07d8a7c0aeaab6b34b5b898d26fb912a2fbd569d6805515", + "sha256:528116f9c9d6f0f01c951ecb1c2e524eacf10e873f6b61aee31d408cfca682e4", + "sha256:54452cfc3fbeb48e215d8ee64166677a2a15ec1a8c3e32e44092d5760422565c", + "sha256:55eec70718ba4ee3eb82a9e7e2d63fb3d88a67f3cd132932c824f901eca6e9d5", + "sha256:59aa3ee6e00030dfe74d0b9e6de66dff762fa94e86bc4f09c5b26583575c689f", + "sha256:5cc7cc08de6b21ac3acf4a3740b49a8b3e19a7002a0788faa4b1709b17697c6d", + "sha256:5e27c014a42f14a97da7e063479f949db3e6a504fd4fad0b536af284c395f3fd", + "sha256:5fd6c514472bb13e12bbba7152fcac68b0c8f03c9504461f501cbd5280523e7b", + "sha256:61a8c25d377fe0810474d780fc6dc4619546e3efc6363f0d5f3ec32c5d35c7a6", + "sha256:626a3e7528fe570162fe731a7fd5e5fb41a99c0ab8c30d4dd33b49447ef45a61", + "sha256:630de4ea28ccb3a32c057bf3dc83976c27a9f2121470fc49455474e8031c6622", + "sha256:68dc6cd44ca694178d26ccd8a165daaa6958bf746c7b5a9cd7c5a3c7630653c5", + "sha256:6932e79b0928b1eed4eee79bb8fb43c36fa406c368428e79744d3069240f423e", + "sha256:6f770da6e25b676c0c342430c8d726229ce5614f6367bf28016c92c0e4b83370", + "sha256:714d0c40c5487996decf67e35a639041f8e69bcbbf6f087a2bb3b7e6a1d54fec", + "sha256:75aafd045c99b5b78cf32951ddfd318f2b61120756c0476606ff05465baa04b2", + "sha256:780ecbcb421d29652a6be3fefdc3dd966ce56eb739715f996eace69096bdbf65", + "sha256:78ca7f042516f0075e2c7ed5030685dbcf531914834d1231b30459614a99634b", + "sha256:794f5ec38a6fe43ca70f7e4a212f65947da8fad91bcc753085f20d2223bb5b32", + "sha256:797aaa4cabfc3f9e1ba2e88e5550d8c40a2993d927c4ef0c5409289e3efbdee9", + "sha256:7a60300dd88468f41c63591dfa901e6f302a6c45111f2de581f34f47c85722f1", + "sha256:7e9f13f943563ecb3a4b637965c6ab382f651b881131cd15b2d453cf63ffe426", + "sha256:80b97d65aef33f896a3353d87f35a77b6957996be35c4edf3ea619e65978d7c3", + "sha256:834361bc462d86c8ed3e33236ba1f1ee090d1bccad22e2bd0f6502f3571a4dca", + "sha256:8798025dfcd0f45e37552817cfbab322722c249e5bada3c2a6fb6ef89941ad8d", + "sha256:8a3bdb35b88a86d4de826ac8dffff455a692d4d30254a6847c324996700a3c11", + "sha256:8d7ce446ccb94ecf20c411f64f5f84ec1a4c7d644a4f2e16c89c0c82fb492bcf", + "sha256:8e45a34731d69a5e3692d5fa44ded884dec6c9fba6e16f9fe15135ae5af0ffe0", + "sha256:943a6e535a20997f2ab9e40bf113ad2bc73cdb990cc204addbba6d0d25f4f4a5", + "sha256:996fee692d6d6f965bb928a3eeda7f97d9ee0517dbd8b3d15c5feb5fb554a624", + "sha256:9c5569ad0d7f186a65344332dc0adecbfe63e2185731fe7e1d3c8835ce34778a", + "sha256:9e20c5c5ee079d94c00e0c327f430c9f35e70a770f39267a95a90197ec6ae495", + "sha256:9e37670341cd92a81fd6bad4d25279dd68385abcca8c80e22ec88f71c0ea0ed3", + "sha256:a0f7317b8e8a475c61418c619520c0e552b0b44a8cacdee0976d43c0f923dc73", + "sha256:aaa0d91d19470302b21eca5c9828982b89e8dcb56c611e4378a21164db55d474", + "sha256:aace3614f8748dda64d31b7853e28625bf7beb3c3b1e8e4a5b95b2ca1f556d0d", + "sha256:ad752064570eacca66297dc0726ac9c0e0d4176000344aecea1e0eb1013ce667", + "sha256:aefe1359360207e4f3d36ea62937d0e2336a4f8c0a0949be6aec56e2e164a4bc", + "sha256:af4ef1da17c482e09d4e3a3fd18051225e5db6c274be17689ee3ff03a4b60c8c", + "sha256:b00dd9a26a72243686eeb3c8b326c91ba895474a097556ba73ca2cd48f082237", + "sha256:b06692bd45eec8c7ff50aaaca3d11638273e96cefaba45f1cf726234e6692631", + "sha256:b081f08ee0d8c13cad22c5c87371d883b30203123555558c0368ba491d4b304f", + "sha256:b2964ddbac5e35da4b41a151793798f12e6d90ee29993ad6a3c5c8bc0dc3f911", + "sha256:b4342aef07022684ede70271611316e69d6669fc06575bb3627402b0c4209583", + "sha256:b7ee9add07565b2f1d88767e703f630be5e500ebb74a8c4b15f4575379baf769", + "sha256:bafcf8039b79c18b443221bd74f7409edbaba080c30c184704ea3f5ed48df3c4", + "sha256:bb84b2a0d8b73d6f1d2a958bbfe19246d53ef241451e228ef09817b296a9d6a1", + "sha256:be53636e9e88d63922c41e681b2c98150b6c222b6f7747a61ada5a66b1592e03", + "sha256:c61ecf2cacf7225059fd7d3eba6aedf5e9410a49b187553022d79a4b7c1fcc19", + "sha256:c6c3083677c887e7f33f4446daadb997c4fa59e6551807463c11f046628d10e9", + "sha256:c6e439c4dd3ff4325cbd36280ea19d84d3054d80e087005bf4df306affb2d54a", + "sha256:c7b8c27f846b5a09e2a4aac926db38346eda7192d7daeacb8a7697531750affe", + "sha256:cb3fb18b82c2ea586a243897df5c9338438b9c87e6b1ae0ebf066a5c202fe6df", + "sha256:cc7d3fcc91ebadd5f07570d5f0903d96feed9963a6120b0024037ea7c4226c68", + "sha256:cc94a2e2d49ad8feb428861d1c9b8d94eecc97a9c136d424768605862e23725a", + "sha256:cd98461e13c5a6f37dc118af4df0de4edc4e66c4c1e2f585e7e34131314756ce", + "sha256:cec6446bbd9c537650273055c5fd2aa71f5b7827e0dc49a836759ddea04d025d", + "sha256:d23581f4f94032c1c871d9fa069d7e89e6ec8665c927e5f945bec435922e2daf", + "sha256:d2a743c0f20db581462922bdf5dad30d5aa553542df89352e31c107e2d4c3734", + "sha256:d7f0927afa87aae9eba04f6d10ea67e7892565f1fdcefc615cff3709f1917f75", + "sha256:db92ca05c37b622702bb92d28b4580620ecda597cd85fcfdc5ac6528d7f17524", + "sha256:dc83f52c931576aac2e18b1d3af49db49b7d58ebdb23dd2abd4f2ca69dda7b37", + "sha256:e21584d45937efade670eba6feca04ae0f4ec6ac183cc543f7d3eedf3a6970c6", + "sha256:e8ff4fc1468acedf2c127e22d107965d8f4a382aff31e2cbad3d0d7bf444e047", + "sha256:ea4dbd636f74a1e60bcc1e2fcc7788a8dcafc2802428d6f5e0ae7dc70a17dd54", + "sha256:ea9cb3f1b218f34dc64abec474cbffcaff301edece34602d6d2fb6418d4d0693", + "sha256:ec85a34f8ca40d9d1d86c261614cdf5a3b1ef96c73c4c2ef1d6cb93edb37a4ba", + "sha256:f3f7d017a72eeb6f87da4290dca558d529d7d73a2011adc1fd9ea9553c8f448c", + "sha256:f44b37d9a15a8012bd1793300e16462a6d19bc3aaf0ed125e244e48ec4ffe8d3", + "sha256:f515ba05ea6ee66abc54c824073a8cabcc91a8e8435cb549f7f3b68e51eadfbe", + "sha256:f7a7183b6c3e27157c7e00d0d9239a3bf1ff1b14724b87611992ed4ba21416fe", + "sha256:f9c62c6127726dc6d7a3a2488cb85dc0502a68c0da6164fc3383448590ddcedf", + "sha256:fa142d4255210b47c5891189b7318ed0efd19ddf1780c95d19a0b310a86b765e", + "sha256:faf570c2aed37be51897c009bf8ef2edd0a7c67694470b0aed97336ba4dce6be", + "sha256:fc10cba57eec3bdec8a4f0a15cbd352b85a5682e9119b88051c2265879b3aaed" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==0.3.1" + }, "rsa": { "hashes": [ "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", @@ -2915,12 +2979,12 @@ }, "uvicorn": { "hashes": [ - "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", - "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d" + "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", + "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.45.0" + "version": "==0.46.0" }, "webencodings": { "hashes": [ @@ -2998,158 +3062,205 @@ }, "xai-sdk": { "hashes": [ - "sha256:ca87a830d310fb8e06fba44fb2a8c5cdf0d9f716b61126eddd51b7f416a63932", - "sha256:fe58ce6d8f8115ae8bd57ded57bcd847d0bb7cb28bb7b236abefd4626df1ed8d" + "sha256:672ec5b864a136a5ec09f9b8bf904c84f601657257b9967eed93f4ce7c4256c3", + "sha256:f5b83082df0969718e68d0bdf92fd57348b65f156682520261660c47caf958ad" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==1.11.0" + "version": "==1.12.1" }, "xxhash": { "hashes": [ - "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", - "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", - "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", - "sha256:01be0c5b500c5362871fc9cfdf58c69b3e5c4f531a82229ddb9eb1eb14138004", - "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", - "sha256:02ea4cb627c76f48cd9fb37cf7ab22bd51e57e1b519807234b473faebe526796", - "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", - "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", - "sha256:0d50101e57aad86f4344ca9b32d091a2135a9d0a4396f19133426c88025b09f1", - "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", - "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", - "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", - "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", - "sha256:18b242455eccdfcd1fa4134c431a30737d2b4f045770f8fe84356b3469d4b919", - "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", - "sha256:1fc1ed882d1e8df932a66e2999429ba6cc4d5172914c904ab193381fba825360", - "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", - "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", - "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", - "sha256:2762bfff264c4e73c0e507274b40634ff465e025f0eaf050897e88ec8367575d", - "sha256:277175a73900ad43a8caeb8b99b9604f21fe8d7c842f2f9061a364a7e220ddb7", - "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", - "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", - "sha256:2ab89a6b80f22214b43d98693c30da66af910c04f9858dd39c8e570749593d7e", - "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", - "sha256:2f171a900d59d51511209f7476933c34a0c2c711078d3c80e74e0fe4f38680ec", - "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", - "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", - "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", - "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", - "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", - "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", - "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", - "sha256:44e342e8cc11b4e79dae5c57f2fb6360c3c20cc57d32049af8f567f5b4bcb5f4", - "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", - "sha256:45aae0c9df92e7fa46fbb738737324a563c727990755ec1965a6a339ea10a1df", - "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", - "sha256:4903530e866b7a9c1eadfd3fa2fbe1b97d3aed4739a80abf506eb9318561c850", - "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", - "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", - "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", - "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", - "sha256:4da8168ae52c01ac64c511d6f4a709479da8b7a4a1d7621ed51652f93747dffa", - "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", - "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", - "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", - "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", - "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", - "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", - "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", - "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", - "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", - "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", - "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", - "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", - "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", - "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", - "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", - "sha256:6551880383f0e6971dc23e512c9ccc986147ce7bfa1cd2e4b520b876c53e9f3d", - "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", - "sha256:6965e0e90f1f0e6cb78da568c13d4a348eeb7f40acfd6d43690a666a459458b8", - "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", - "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", - "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", - "sha256:780b90c313348f030b811efc37b0fa1431163cb8db8064cf88a7936b6ce5f222", - "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", - "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", - "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", - "sha256:7c35c4cdc65f2a29f34425c446f2f5cdcd0e3c34158931e1cc927ece925ab802", - "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", - "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", - "sha256:7dac94fad14a3d1c92affb661021e1d5cbcf3876be5f5b4d90730775ccb7ac41", - "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", - "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", - "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", - "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", - "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", - "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", - "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", - "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", - "sha256:9085e798c163ce310d91f8aa6b325dda3c2944c93c6ce1edb314030d4167cc65", - "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", - "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", - "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", - "sha256:97460eec202017f719e839a0d3551fbc0b2fcc9c6c6ffaa5af85bbd5de432788", - "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", - "sha256:9e040d3e762f84500961791fa3709ffa4784d4dcd7690afc655c095e02fff05f", - "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", - "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", - "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", - "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", - "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", - "sha256:a75ffc1bd5def584129774c158e108e5d768e10b75813f2b32650bb041066ed6", - "sha256:a87f271a33fad0e5bf3be282be55d78df3a45ae457950deb5241998790326f87", - "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", - "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", - "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", - "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", - "sha256:b0359391c3dad6de872fefb0cf5b69d55b0655c55ee78b1bb7a568979b2ce96b", - "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", - "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", - "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", - "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", - "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", - "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", - "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", - "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", - "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", - "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", - "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", - "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", - "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", - "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", - "sha256:c2f9ccd5c4be370939a2e17602fbc49995299203da72a3429db013d44d590e86", - "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", - "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", - "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", - "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", - "sha256:cc604dc06027dbeb8281aeac5899c35fcfe7c77b25212833709f0bff4ce74d2a", - "sha256:cfbc5b91397c8c2972fdac13fb3e4ed2f7f8ccac85cd2c644887557780a9b6e2", - "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", - "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", - "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", - "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", - "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", - "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", - "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", - "sha256:e4ff728a2894e7f436b9e94c667b0f426b9c74b71f900cf37d5468c6b5da0536", - "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", - "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", - "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", - "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", - "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", - "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", - "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", - "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", - "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", - "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", - "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", - "sha256:ffc578717a347baf25be8397cb10d2528802d24f94cfc005c0e44fef44b5cdd6" + "sha256:01cf5c5333aed26cc8d5eea33b8d6398e085e365a704b7372fabdf7ab06441a9", + "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", + "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", + "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", + "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", + "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", + "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", + "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", + "sha256:0c36f89ba026ccc6fde8f48479a2fd9fc450a736cc7c0d5650acfcff8636282e", + "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", + "sha256:0d23fd49fdc5c8af61fb7104f1ad247954499140f6cb6045b3aa5c99dadbbf28", + "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", + "sha256:1061bc6cec00adf75347b064ee62b220d66d9bc506acaad1418c79eec45a318c", + "sha256:11dd69b1a34b7b9af29012f390825b0cdb0617c0966560e227ca74daa7478ba9", + "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", + "sha256:12c249621af6d50a05d9f10af894b404157b15819878e18f75fcbb0213a77d07", + "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", + "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", + "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", + "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", + "sha256:153c3a4f73563101d4c8102cbff6a5b46f7aa9dbe374eedf1cd3b15fda750566", + "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", + "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", + "sha256:17f8ae90c8e00f225be4899c3023704f23ee6d5638a00c54d6cbe9980068e6f9", + "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", + "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", + "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", + "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", + "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", + "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", + "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", + "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", + "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", + "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", + "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", + "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", + "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", + "sha256:3409b50ddbc76377d938f40a7a4662cd449f743f2c6178fd6162b875bf9b0d4f", + "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", + "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", + "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", + "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", + "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", + "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", + "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", + "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", + "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", + "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", + "sha256:421da671f43a0189b57a4b8be694576308395f92f55ed3badcde67ab95acef81", + "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", + "sha256:44909f79fb7a4950ec7d96059398f46f634534cd95be9330a3827210af5aaebe", + "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", + "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", + "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", + "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", + "sha256:49a88183a3e5ab0b69d9bbfc0180cbdb247e8bada19fd9403c538b3aa3c24176", + "sha256:49e556558eee5c8c9b2d5da03fd36cfa6c99cae95b3c3887ec64ee1a49ed517a", + "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", + "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", + "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", + "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", + "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", + "sha256:50846b9b01f461ee0250d7a701a3d881e9c52ebce335d6e38e0224adc3369f50", + "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", + "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", + "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", + "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", + "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", + "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", + "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", + "sha256:5bf2f1940499839b39fef1561b5ecb6ede9ac34ef4457474e1337fc7ef07c2f3", + "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", + "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", + "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", + "sha256:6318d8b6f6c6c21058928c23289686fc74f37d794170f14b35fecceb515d5e37", + "sha256:646a69b56d8145d85f7fd2289d14fba07880c8a5bda406aa256b407481a61f35", + "sha256:646b8aa66cf0cec9295dfc4e3ac823ee52e338bada9547f5cf2d674212d04b58", + "sha256:6741564a923f082f3c2941c8bb920462ed5b25eaebdd1e161f162233c9a10bc5", + "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", + "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", + "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", + "sha256:6e83179bbb208fb72774c06ba227d6e410fa3797de33d0d4c00e3935f81da7d2", + "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", + "sha256:6f31143e18e6db136455b16f0e4e6eba943e1889127dd7c649b46a50d54dd836", + "sha256:7426ff0dfa76eb47efc2cc59d4a717bfa9dc9938bff5e49e748bca749f6aa616", + "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", + "sha256:7553816512c0abb75329c163a1eee77b0802c3757054b910d6e547bd0dbd16b7", + "sha256:79f9efdbc828b02c681a7cefc6d4108d63811b20a8fb8518a40cb2c13ed15452", + "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", + "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", + "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", + "sha256:7c76f18d1268d3dc1c8b8facef5b48a9c6172d4a49113afa2d91745f555c75ff", + "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", + "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", + "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", + "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", + "sha256:84710b4e449596a6565ab67293858d2d93a54eeec55722d55c8f0a08b6e6de24", + "sha256:85f5c0e26d945b5bb475e0a3d95193117498130baa7619357bdc7869c2391b5a", + "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", + "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", + "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", + "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", + "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", + "sha256:8e7edb98dd4721a2694542a35a0bdb989b42892086fd0216f7c48762dfe20844", + "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", + "sha256:8ff00fcc3eb436617ed8556cf15daf76c2b501248361a065625a588af78a0a02", + "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", + "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", + "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", + "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", + "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", + "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", + "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", + "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", + "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", + "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", + "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", + "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", + "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", + "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", + "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", + "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", + "sha256:a845a59664d5c531525a467470220f8edc37959e0a6f8e734ffb6654da5c4bee", + "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", + "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", + "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", + "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", + "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", + "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", + "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", + "sha256:b081119a6115d2db49e24ab6316b7dcd74651271e9630c7b979999bd0c11973d", + "sha256:b4e6fe5c6f4e6ad67c1374a7c85c944ca1a8d9672f0a1628201ea5c58e0d4596", + "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", + "sha256:b5cd29840505631c6f7dbb8a5d34b742b5e6bbda38fe0b9f54e825f3ea6b61dc", + "sha256:b7ffeaada9f8699be63d639536b0b60dff73b7d3325b7475c5bc8fdbf4eed47f", + "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", + "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", + "sha256:c21625d710f971dd58ae92c5b0c2ca109d2ceba939becc937c5cff9268cd451b", + "sha256:c3c0059e642b2e7e15c77341a8946f670a403fcd57feecc9e47d68555b9b1c08", + "sha256:c40a8ad7d42fe779ac429fe245ed44c54f30e2549173559d70b7167922431701", + "sha256:c4fd8acc6e32596350619896feb372033c0920975992d29837c32853bb1feacd", + "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", + "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", + "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", + "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", + "sha256:ca12a6d683957a651e3203c1458ff8ab4119aae7363e202e2e820cbfe02df244", + "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", + "sha256:cce1e2782efaf0f595c17fe331cf295882a268c04d5887956e2fc0d262b0fb3a", + "sha256:cd8ab85c916a58d5c8656ea15e3ce9df836fe2f120a74c296e01d69fab2614b4", + "sha256:cee88dfaa6b1b2bfadd3c031fa5f05584870e62fb05dc500942e9900c44fcfda", + "sha256:cf7424a11a81f59b6f0abdccfbe27c87d552f059ef761471f98245b46b71b5c9", + "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", + "sha256:d1442628c84afa453a9a06a10d74d890d3c1b1e4da313b48b16e1001895fdac4", + "sha256:d33fcd60f5546e4b7538a8ae2b2027b51e9905b9a264c32df56de32202997155", + "sha256:d41fcda2fa8ca682ebca134a2f2dc02575ba549267585597e73061565795f475", + "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", + "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", + "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", + "sha256:da5b373b1dfce210b8620bdb5d9dae668fe549de67948465dcc39e833d4bbe28", + "sha256:dbcd969178d417c2bbd60076f8e407a0e2baf90976eed21c1b818ff8292b902f", + "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", + "sha256:dea2fd4ae84b14aa883ac713faffbb5c26764ec623e00ed34737895be523d1fa", + "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", + "sha256:e8ff6ec73110f610425caef3ea875afbfc34caa542f01df3a80f45aadeb9f906", + "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", + "sha256:ea85a647fd33d5cf2840027c2e0b7da8868b220d3f05e3866efdda78c440d499", + "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", + "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", + "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", + "sha256:f13319fb8e6ef636f71db3c254d01cbf1543786e10a945a3ff180144618e25b6", + "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", + "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", + "sha256:f1e65d52c2d526734abecb98372c256b7eacce8fdc42e0df8570417fb39e2772", + "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", + "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", + "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", + "sha256:f749e52b539e2934171a3718cbf061dc12d74719eddde2d0f025c99637ddbe01", + "sha256:f99a15867cbf9fcf753ea72b82a1d6fe6552e6feea3b4842c86a951525685bbb", + "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", + "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", + "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", + "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", + "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", + "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", + "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", + "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", + "sha256:fe820f104473d1516ecd628993690bc1f79b0e699f32711d42a5a70b3d0f8170" ], - "markers": "python_version >= '3.7'", - "version": "==3.6.0" + "markers": "python_version >= '3.8'", + "version": "==3.7.0" }, "yarl": { "hashes": [ @@ -3402,11 +3513,11 @@ "develop": { "certifi": { "hashes": [ - "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", - "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" + "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", + "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" ], "markers": "python_version >= '3.7'", - "version": "==2026.2.25" + "version": "==2026.4.22" }, "cfgv": { "hashes": [ @@ -3576,11 +3687,11 @@ }, "idna": { "hashes": [ - "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", - "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254" + "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", + "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" ], "markers": "python_version >= '3.8'", - "version": "==3.12" + "version": "==3.13" }, "iniconfig": { "hashes": [ @@ -3757,28 +3868,28 @@ }, "ruff": { "hashes": [ - "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", - "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", - "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", - "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", - "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", - "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", - "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", - "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", - "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", - "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", - "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", - "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", - "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", - "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", - "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", - "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", - "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", - "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d" + "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", + "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", + "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", + "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", + "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", + "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", + "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", + "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", + "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", + "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", + "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", + "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", + "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", + "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", + "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", + "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", + "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", + "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.15.11" + "version": "==0.15.12" }, "the-agent": { "editable": true, @@ -3795,11 +3906,11 @@ }, "virtualenv": { "hashes": [ - "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", - "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada" + "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", + "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35" ], "markers": "python_version >= '3.8'", - "version": "==21.2.4" + "version": "==21.3.1" } } } diff --git a/config/logos.yaml b/config/logos.yaml new file mode 100644 index 00000000..573bd68f --- /dev/null +++ b/config/logos.yaml @@ -0,0 +1,8 @@ +logos: + + agent_logo_color: "https://raw.githubusercontent.com/appifyhub/agent-landing/refs/heads/main/graphics/svg/logo.svg" + agent_logo_light: "https://raw.githubusercontent.com/appifyhub/agent-landing/refs/heads/main/graphics/svg/monochrome/logo-white.svg" + agent_logo_dark: "https://raw.githubusercontent.com/appifyhub/agent-landing/refs/heads/main/graphics/svg/monochrome/logo-black.svg" + + x_logo_light: "https://raw.githubusercontent.com/appifyhub/agent-landing/refs/heads/main/graphics/svg/monochrome/x-twitter-white.svg" + x_logo_dark: "https://raw.githubusercontent.com/appifyhub/agent-landing/refs/heads/main/graphics/svg/monochrome/x-twitter-black.svg" diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index 83895feb..4c82aade 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.11.2 + version: 5.12.0 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/openspec/changes/archive/2026-05-06-add-social-post-card-tool/.openspec.yaml b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/.openspec.yaml new file mode 100644 index 00000000..905325fd --- /dev/null +++ b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-04 diff --git a/openspec/changes/archive/2026-05-06-add-social-post-card-tool/design.md b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/design.md new file mode 100644 index 00000000..f234e230 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/design.md @@ -0,0 +1,133 @@ +## Context + +The codebase already has the building blocks for a tweet-to-card pipeline: + +- `TwitterStatusFetcher` calls the X API v2, caches the result for 52 weeks, and currently flattens it into a text blob (with computer-vision photo descriptions inline). +- `WebFetcher` auto-routes Twitter URLs to that fetcher when the LLM calls `fetch_web_content`. +- `ImageUploader` posts binary image bytes to imgbb and returns a URL โ€” the same return-shape the new tool will use. +- `UrlShortener` shortens any long URL to a slug for the card footer. +- `tool_choice_resolver` resolves `ToolType.api_twitter` to a `ConfiguredTool` with the X bearer. +- `ImageDraw`/Pillow is available, but no HTML/SVG renderer is. + +What's missing: a structured-data path out of the fetcher (the current text path runs CV per photo, which we don't need for a card with embedded photos), an SVG-based card renderer, and the LLM tool binding. + +The card design is a fixed-template layout with three blocks: header (circular avatar + name/handle/datetime + agent logo), tweet text (auto-wrapped), and a photo grid (max 4 in a 2ร—2 with selective corner rounding). A subtle footer with X icon and a shortened original URL closes the card. Theme is a linear gradient driven by the dominant color of the profile photo, with documented fallbacks. + +## Goals / Non-Goals + +**Goals:** + +- An LLM-callable tool `render_social_post(url, aspect_ratio)` that returns a hosted PNG URL of a card-styled tweet. +- Reuse the X API call and its 52-week cache; no duplicate API hits when both text and structured paths are exercised on the same tweet. +- Reasonable rendering quality: anti-aliased text and corners, real gradients, embedded photos, no browser engine, no system dependencies on the host. +- Forward-compatible naming so the same tool can later cover other social platforms (Mastodon, Bluesky, etc.) without renaming. + +**Non-Goals:** + +- Decoding GIFs or sampling videos. We only render the X-supplied `preview_image_url` poster frame. +- Generating multiple cards per call, threading, or quote-tweets. One URL โ†’ one card. +- Replacing or deprecating the text path through `fetch_web_content`; both paths exist side by side. +- Persistent storage of rendered cards. The card lives long enough for the chat platform to consume it; messenger apps persist the file on their end. +- A pluggable render-strategy abstraction. We start with one renderer (resvg) and one source (X). Generalize later if a real second case appears. + +## Decisions + +### 1. SVG โ†’ PNG via `resvg-py`, not Pillow-only or HTML/CSS + +**Decision:** Build a card SVG (Jinja2 or f-string template), rasterize via `resvg-py`. + +**Why:** Anti-aliased rounded corners, drop shadows, linear gradients, multi-line text via ``, and circular profile crops via `` are all native SVG primitives. `resvg-py` ships pre-built `manylinux_2_17_x86_64` wheels, has zero system dependencies, and produces reproducible cross-platform output (matters for testing). + +**Alternatives considered:** +- *Pillow-only*: Possible, but rounded corners need supersampling (render at 2โ€“4ร— and downscale), drop shadows need manual blur compositing, gradients need per-row fills, and multi-line text needs hand-rolled word wrapping. ~5ร— more code than the SVG path, and quality depends on the supersampling factor. +- *Playwright (headless Chromium)*: Best HTML/CSS fidelity but adds ~150โ€“300 MB to the container, plus per-request browser startup latency. Overkill for one fixed template. +- *cairosvg*: Equally good rendering, but requires `libcairo2` system package. Trivial on Linux but adds a non-Python dep to the Dockerfile. +- *weasyprint / imgkit / wkhtmltopdf*: All require system packages and target PDF first; PNG output is secondary. CSS support is older or partial. + +### 2. Structured-mode on the existing fetcher, not a separate fetcher + +**Decision:** Refactor `TwitterStatusFetcher` so the X API call and result-caching live below the text-formatting step. Expose two read methods: `as_text()` (current behavior, with CV) and `as_structured()` (new, no CV; returns a typed dataclass/dict). + +**Why:** The expensive part of fetching a tweet is the X API call plus CV per photo. The text path needs CV; the card path doesn't (we embed actual photos). Separating "fetch + cache raw" from "format" lets both modes share the API call and the cache. Adds one dataclass, no duplicated code. + +**Alternative considered:** Build a parallel `TwitterStatusDataFetcher` that hits the API independently. Rejected โ€” duplicates the cache logic, doubles the API rate-limit footprint, and drifts over time. + +**Cache key strategy:** The current text cache stores formatted strings under `twitter-status-fetcher` and includes CV results. The structured cache stores the raw API JSON dict (no CV) under `twitter-status-fetcher-structured`. Two distinct keys because the values are fundamentally different shapes. The text path's cache is untouched; existing entries remain valid. + +### 3. Aspect ratio is a target, not a hard clamp + +**Decision:** The LLM passes `aspect_ratio: "1:1" | "2:3" | "3:2"` (default `"2:3"`). It determines: + +- Card width: 1080 (portrait/square), 1620 (landscape). +- Minimum height: `width ร— (numerator / denominator inverted as appropriate for orientation)`. +- Photo-grid branch: portrait โ†’ 1 column; landscape/square โ†’ 2 columns, downgraded to 1 if โ‰ฅ50% of photos are landscape. + +Actual rendered height is `max(target_height, content_driven_height)`. Long tweets and tall photo stacks expand the card vertically. The chat-side photo-sending pipeline resizes the final asset, so we don't need to clamp. + +**Why:** Hard-clamping height truncates tweet text or photos. A target gives the LLM control over visual emphasis (vertical for stories-style sharing, landscape for desktop chats) without breaking long content. + +### 4. Theme color extraction priority + +**Decision:** Three-tier fallback: + +1. Dominant color of the profile photo (downloaded once, computed via `Image.quantize()`). +2. Combined dominant color of media photos (concatenate downsized versions, quantize once). +3. Agent brand purple-blue, fetched at first use from `agent.appifyhub.com` and pinned in code. + +Then derive a gradient: second stop = first stop with ยฑ10% lightness shift (away from neutral โ€” bright stays bright direction, dark goes darker) and ยฑ10ยฐ hue rotation toward red/blue (whichever the original is closer to). Text color = black or white by contrast against the dominant color. + +**Why:** Profile photos are present on every tweet; media photos are sometimes absent. Brand fallback ensures a usable card even with a default avatar. Pillow's `Image.quantize()` covers dominant-color extraction without a new dependency. + +### 5. Footer is a brand-light element, not a CTA + +**Decision:** Bottom-of-card row with X icon (subtle SVG glyph) + shortened original URL via `UrlShortener`, both at low opacity. No "Open in X" button. + +**Why:** The card's purpose is sharing, not driving traffic. The shortened URL gives provenance without competing with the tweet content visually. + +### 6. Photo limits and media variants + +**Decision:** Render at most 4 media items (X enforces this โ€” 4 photos OR 1 GIF OR 1 video, never mixed). For animated GIFs and videos, pull `media.preview_image_url` from the X API response; treat them as still images. No play-button overlay, no inline indication that it's a moving format. + +**Why:** Matches the X media model. The user explicitly opted out of an overlay for visual cleanliness. + +### 7. Profile image variant: `_bigger` (73ร—73) + +**Decision:** Transform the X-returned `profile_image_url` from `_normal` (48ร—48) to `_bigger` (73ร—73) via simple string replacement. Render in a 64-ish px circular crop. + +**Why:** `_bigger` is the closest official variant to a clean rendering size. `original` (no suffix) returns full-size and is overkill for a thumb-style avatar. `_400x400` is not an official variant per X docs. + +### 8. Failure mode is structured error, not text fallback + +**Decision:** If any step fails (URL doesn't resolve to a tweet, API call errors, photo download fails, render fails, upload fails), the tool returns a JSON error to the LLM. It does not fall back to returning the tweet's text content. + +**Why:** Keeps the tool contract crisp. The LLM can re-attempt by calling `fetch_web_content` if it wants the text. Mixing modes inside a single tool blurs intent. + +## Risks / Trade-offs + +- **Risk:** `resvg-py` doesn't support every SVG filter primitive. โ†’ **Mitigation:** Constrain the template to widely-supported primitives (linearGradient, clipPath, feGaussianBlur, basic text). If a real gap appears, fall back to `cairosvg` (drop-in API, costs `libcairo2` in the container). +- **Risk:** X media URLs may require authentication in some tenants. โ†’ **Mitigation:** The Twitter tool already holds a bearer; the photo-downloader can pass `Authorization: Bearer โ€ฆ` if needed. Verify on first integration test; if public works, skip the header. +- **Risk:** Long tweets push the card very tall, breaking the chat platform's preview crop. โ†’ **Mitigation:** This is the explicit design choice (content beats layout). If a chat platform truncates badly in practice, we can introduce a soft max-height with a "..." overflow later. +- **Risk:** Dominant-color extraction on busy photos produces ugly themes. โ†’ **Mitigation:** Quantize to a small palette (e.g., 8 colors) and pick the most-saturated rather than the most-frequent. Tune at implementation time against real samples. +- **Risk:** Heebo variable font rendering differences across resvg versions. โ†’ **Mitigation:** Pin `resvg-py` version; treat font rendering as part of the regression surface (snapshot tests if we add visual tests later). +- **Risk:** New cache prefix means double cache footprint per tweet (text + structured). โ†’ **Acceptable.** The cache TTL is 52 weeks but values are small JSON; storage cost is negligible compared to the API rate-limit savings. +- **Trade-off:** Adding a third dependency (`resvg-py`) for one feature. โ†’ **Accepted.** The cost of writing a Pillow-based renderer is higher in code, fragility, and maintenance than the cost of one well-maintained pip wheel. + +## Migration Plan + +1. Add `resvg-py` to `Pipfile`, run `pipenv install`. +2. Drop Heebo TTF under `src/assets/fonts/` (path scouted from existing project asset conventions). +3. Refactor `TwitterStatusFetcher`: extract API+cache into a private `__fetch_raw()`, add `as_text()` (current behavior) and `as_structured()` (new) public methods. Existing call sites continue to work via `as_text()` shim or rename. +4. Add structured-mode cache under new prefix; existing text-mode cache untouched. +5. Build `social_cards/` module: theme extraction, SVG template, photo download, renderer, top-level orchestrator. +6. Wire `render_social_post` into `llm_tool_library.py` and the `LLMToolLibrary.ALL_LLM_TOOLS` registry. +7. Wire DI factory in `di/di.py` for the renderer and orchestrator. +8. No DB migrations; no API contract changes. + +**Rollback:** This is purely additive. To roll back, remove the new tool from the LLM registry and revert the structured-mode method. The text path is unchanged throughout. + +## Open Questions + +- **Agent + X logo SVG URLs**: User will supply real URLs. Until then, ship placeholder SVGs (single-letter monogram for agent, simple "X" glyph for the X icon) so the renderer is end-to-end testable. +- **Brand purple-blue hex values**: Fetch from `agent.appifyhub.com` at implementation time and pin in a constants module. +- **Font asset path**: Confirm whether the project already has a fonts/assets directory; otherwise create `src/assets/fonts/`. +- **Expiration of uploaded card**: Default `ImageUploader` 5-min should suffice for messenger send latency. Bump to 30 min if integration tests show race conditions. diff --git a/openspec/changes/archive/2026-05-06-add-social-post-card-tool/proposal.md b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/proposal.md new file mode 100644 index 00000000..9c31deb4 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/proposal.md @@ -0,0 +1,32 @@ +## Why + +Today the LLM can fetch a Twitter/X post as text via `fetch_web_content`, but cannot produce a shareable visual rendition of it. Users who want to share a tweet visually (forwarded into a chat, attached to a message) get a flat text excerpt instead of a card-styled image. We want a dedicated LLM tool that turns a social post URL into a styled, brand-themed card image, starting with Twitter/X and structured to extend to other platforms later. + +## What Changes + +- Add a new LLM-callable tool `render_social_post(url, aspect_ratio)` that produces a card image URL for a social post. +- Extend `TwitterStatusFetcher` with a structured-output mode (`as_structured()`) alongside the existing text mode (`as_text()`); cache the raw API response so both modes share the cache. +- Add `profile_image_url` to `user.fields`, `created_at` to `tweet.fields`, and `preview_image_url` to `media.fields` in the X API request. +- Bump the Twitter cache key prefix to `twitter-status-fetcher-structured` for the new structured cache (text path retains its existing key). +- Introduce an SVG-based card renderer that composes header (avatar, name, handle, datetime, agent logo), tweet text, photo grid (max 4, 2ร—2), and a low-opacity footer (X icon + shortened URL via `UrlShortener`). +- Add `resvg-py` as a new pip dependency for SVG โ†’ PNG rasterization (zero system deps, manylinux wheels). +- Ship Heebo Variable TTF (OFL) with the project for card text rendering. +- Theme color is selected from the dominant color of the profile photo, falling back to combined dominant of media photos, falling back to the agent's brand purple-blue. +- Card width is fixed per aspect ratio (1080 portrait/square, 1620 landscape); height is content-driven and grows past the target ratio when text or photos demand it. + +## Capabilities + +### New Capabilities +- `social-post-card-rendering`: LLM-callable rendering of social network posts (initially Twitter/X) into styled card images, plus the structured-data path that supplies the renderer. + +### Modified Capabilities + + +## Impact + +- **New code**: `src/features/social_cards/` (renderer, theme extraction, SVG template, photo download); new LLM tool function in `src/features/chat/llm_tools/llm_tool_library.py`; structured-mode method on `src/features/web_browsing/twitter_status_fetcher.py`. +- **New asset**: Heebo Variable TTF under `src/assets/fonts/` (or equivalent location scouted at implementation time). +- **New deps**: `resvg-py` added to `Pipfile`. +- **API request changes**: extra fields on the X API call inside `TwitterStatusFetcher`; cache key prefix change for the structured cache (existing text cache untouched). +- **Reuses existing**: `tool_choice_resolver` for `ToolType.api_twitter`, `ImageUploader` (imgbb) for hosting the rendered PNG, `UrlShortener` for the footer link, `resolve_tweet_id` for URL parsing. +- **No breaking changes** to existing LLM tools or external APIs. The text path through `fetch_web_content` is unchanged. diff --git a/openspec/changes/archive/2026-05-06-add-social-post-card-tool/specs/social-post-card-rendering/spec.md b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/specs/social-post-card-rendering/spec.md new file mode 100644 index 00000000..d0a4fcf4 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/specs/social-post-card-rendering/spec.md @@ -0,0 +1,163 @@ +## ADDED Requirements + +### Requirement: LLM Tool for Social Post Card Rendering +The system SHALL expose an LLM-callable tool named `render_social_post` that accepts a social network post URL and an optional aspect ratio, and returns a hosted image URL of a rendered card. The tool SHALL be registered in the LLM tool library and bound to the chat model alongside the existing tools. + +#### Scenario: Successful render of a Twitter/X post +- **WHEN** the LLM invokes `render_social_post` with a valid Twitter/X URL and `aspect_ratio="2:3"` +- **THEN** the tool fetches the tweet's structured data, renders a card image with header, tweet text, and any photos, uploads the resulting PNG, and returns a JSON success response containing the hosted image URL + +#### Scenario: Default aspect ratio +- **WHEN** the LLM invokes `render_social_post` with only a `url` argument and no `aspect_ratio` +- **THEN** the tool uses portrait orientation (`"2:3"`) as the default + +#### Scenario: Invalid social network URL +- **WHEN** the LLM invokes `render_social_post` with a URL that does not resolve to a supported social post (e.g., `https://example.com/foo`) +- **THEN** the tool returns a JSON error response and does NOT fall back to rendering the URL as plain text + +#### Scenario: Render or upload failure +- **WHEN** any internal step (API call, photo download, SVG render, image upload) raises an error +- **THEN** the tool returns a JSON error response with a descriptive message and does NOT fall back to text content + +### Requirement: Aspect Ratio Support +The tool SHALL support the aspect ratio values `"1:1"`, `"2:3"`, and `"3:2"`. The aspect ratio SHALL determine the card's fixed pixel width and minimum height, and SHALL drive the photo grid layout decision. The actual rendered card height SHALL grow beyond the target height when content (tweet text length or photo count) requires it. + +#### Scenario: Portrait card width and minimum height +- **WHEN** rendering with `aspect_ratio="2:3"` +- **THEN** the card width is fixed at 1080 pixels and the minimum height is 1620 pixels + +#### Scenario: Landscape card width and minimum height +- **WHEN** rendering with `aspect_ratio="3:2"` +- **THEN** the card width is fixed at 1620 pixels and the minimum height is 1080 pixels + +#### Scenario: Square card dimensions +- **WHEN** rendering with `aspect_ratio="1:1"` +- **THEN** the card width and minimum height are both 1080 pixels + +#### Scenario: Content exceeds target height +- **WHEN** the rendered tweet text and photos require more vertical space than the minimum height for the chosen aspect ratio +- **THEN** the card grows vertically to fit the content; the width remains at the aspect ratio's fixed value + +### Requirement: Photo Grid Layout +The card SHALL render up to 4 photos in a grid whose column count depends on card orientation and aggregate photo orientation. Outer corners of the photo block SHALL be rounded; inner edges between adjacent photos SHALL remain square. + +#### Scenario: Portrait card forces single column +- **WHEN** `aspect_ratio="2:3"` and the tweet contains 2 or more photos +- **THEN** photos are stacked in a single column + +#### Scenario: Landscape or square card with majority portrait photos +- **WHEN** `aspect_ratio` is `"1:1"` or `"3:2"` and fewer than 50% of the tweet's photos are landscape +- **THEN** photos are arranged in a 2-column grid + +#### Scenario: Landscape or square card with majority landscape photos +- **WHEN** `aspect_ratio` is `"1:1"` or `"3:2"` and 50% or more of the tweet's photos are landscape +- **THEN** photos are stacked in a single column + +#### Scenario: Selective corner rounding +- **WHEN** rendering a multi-row photo grid +- **THEN** only the top-left and top-right corners of the first row, and the bottom-left and bottom-right corners of the last row, are rounded; all interior corners remain square + +### Requirement: Card Header Layout +The card SHALL display a fixed-height header containing, on the left, a circular profile photo followed by the post author's name with handle and a formatted datetime, and on the right, the agent's service logo. + +#### Scenario: Profile photo retrieval +- **WHEN** the tweet's user data includes a `profile_image_url` +- **THEN** the renderer fetches the `_bigger` (73ร—73) variant by transforming the URL suffix and renders it cropped to a circle + +#### Scenario: Name and handle rendering +- **WHEN** the user has a non-empty display name +- **THEN** the header renders ` (@)` on the first text row + +#### Scenario: Empty display name +- **WHEN** the user has no display name +- **THEN** the header renders `@` only on the first text row + +#### Scenario: Datetime format +- **WHEN** rendering the header with the tweet's `created_at` timestamp +- **THEN** the datetime is formatted as `YYYY-MM-DD ยท UTC h:mm AM/PM` (e.g., `2026-05-04 ยท UTC 4:13 PM`) + +#### Scenario: Service logo placement +- **WHEN** rendering the header +- **THEN** the agent service logo (an SVG) is placed at fixed size on the right side of the header row + +### Requirement: Card Body and Footer +The card body SHALL render the tweet text in a single column with automatic word wrapping, followed by the photo grid (if any). A footer row at the bottom of the card SHALL render the X icon followed by a shortened version of the original tweet URL at low opacity. + +#### Scenario: Tweet text wrapping +- **WHEN** rendering tweet text that exceeds one line at the card's text width +- **THEN** text wraps at word boundaries and the card body grows vertically to accommodate all lines + +#### Scenario: Footer URL shortening +- **WHEN** rendering the footer +- **THEN** the original tweet URL is shortened via `UrlShortener` and the resulting short URL is displayed next to the X icon + +#### Scenario: Footer styling +- **WHEN** rendering the footer +- **THEN** the X icon and short URL are rendered at reduced opacity to remain subtle relative to the tweet content + +### Requirement: Theme Color Selection +The card SHALL apply a linear-gradient background derived from a primary color chosen by a three-tier fallback. The gradient's second color SHALL be derived from the primary by lightness and hue shifts. Foreground text color SHALL be chosen for contrast against the primary color. + +#### Scenario: Primary color from profile photo +- **WHEN** the tweet's profile photo can be downloaded and analyzed +- **THEN** the primary color is the dominant color extracted from the profile photo + +#### Scenario: Primary color from media photos +- **WHEN** no profile photo is available or its dominant color cannot be determined, and the tweet has media photos +- **THEN** the primary color is the dominant color across all media photos combined + +#### Scenario: Default brand color fallback +- **WHEN** neither the profile photo nor media photos yield a usable dominant color +- **THEN** the primary color is the agent's brand purple-blue (sourced from `agent.appifyhub.com`) + +#### Scenario: Gradient second-color derivation +- **WHEN** the primary color is selected +- **THEN** the gradient's second stop is the primary color shifted by approximately 10% in lightness (away from neutral) and approximately 10ยฐ in hue toward red or blue + +#### Scenario: Foreground text contrast +- **WHEN** rendering text on the themed background +- **THEN** the text color is black or white, chosen to maximize contrast against the primary color + +### Requirement: Image Output and Hosting +The renderer SHALL produce a PNG image, upload it via the existing `ImageUploader` (imgbb), and return the hosted URL to the LLM. No persistent storage of the rendered card is required beyond the upload provider's expiration. + +#### Scenario: PNG output and upload +- **WHEN** the SVG card is rasterized +- **THEN** the renderer produces PNG bytes, passes them to `ImageUploader`, and returns the resulting hosted URL string in the LLM tool's success response + +### Requirement: Structured Mode in TwitterStatusFetcher +`TwitterStatusFetcher` SHALL provide both a text-output mode (existing behavior) and a structured-output mode that returns the raw tweet data as a typed object without invoking computer-vision photo analysis. Both modes SHALL share the underlying X API call but use distinct caches. + +#### Scenario: Structured mode returns raw data +- **WHEN** `as_structured()` is called +- **THEN** the fetcher returns a typed object containing the user (name, handle, bio, profile image URL), the tweet (text, language, created_at), and a list of media items (URLs and types) without running computer vision over photos + +#### Scenario: Text mode preserves existing behavior +- **WHEN** `as_text()` is called +- **THEN** the fetcher returns a formatted text string equivalent to the current `execute()` output, including computer-vision descriptions of photos + +#### Scenario: Structured mode cache key +- **WHEN** the structured mode caches its result +- **THEN** the cache key uses prefix `twitter-status-fetcher-structured`, distinct from the text mode's existing prefix + +#### Scenario: Cache reuse across modes +- **WHEN** the same tweet is requested in both modes within the cache TTL +- **THEN** each mode hits its own cache on the second call; the X API is called at most once per mode within the TTL window + +### Requirement: Extended X API Request Fields +The X API request issued by `TwitterStatusFetcher` SHALL include `profile_image_url` in `user.fields`, `created_at` in `tweet.fields`, and `preview_image_url` in `media.fields` to support the card-rendering data needs. + +#### Scenario: Request includes new fields +- **WHEN** `TwitterStatusFetcher` issues a request to the X API +- **THEN** the query parameters include `user.fields=name,username,description,profile_image_url`, `tweet.fields=lang,text,created_at`, and `media.fields=url,type,preview_image_url` + +### Requirement: GIF and Video Handling +The renderer SHALL treat animated GIFs and videos as still images by using the X-supplied `preview_image_url` poster frame. No play-button overlay or motion indicator SHALL be added. + +#### Scenario: Animated GIF in tweet +- **WHEN** the tweet contains a media item of type `animated_gif` +- **THEN** the renderer uses the item's `preview_image_url` as the rendered photo with no overlay + +#### Scenario: Video in tweet +- **WHEN** the tweet contains a media item of type `video` +- **THEN** the renderer uses the item's `preview_image_url` as the rendered photo with no overlay diff --git a/openspec/changes/archive/2026-05-06-add-social-post-card-tool/tasks.md b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/tasks.md new file mode 100644 index 00000000..6477c4a1 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-add-social-post-card-tool/tasks.md @@ -0,0 +1,60 @@ +## 1. Dependencies and Assets + +- [x] 1.1 Add `resvg-py` to `Pipfile` and run `pipenv install` +- [x] 1.2 Scout existing asset conventions; create `src/assets/fonts/` if not present +- [x] 1.3 Drop Heebo Variable TTF (from `~/Downloads/Heebo/Heebo-VariableFont_wght.ttf`) into the fonts directory and verify `OFL-Heebo-Variable.txt` is included for license compliance +- [x] 1.4 Fetch agent brand purple-blue hex values from `agent.appifyhub.com` and pin in a constants module (e.g., `src/features/social_cards/brand.py`) +- [x] 1.5 Create placeholder agent logo SVG and X icon SVG to use until real assets are provided + +## 2. TwitterStatusFetcher Refactor (Structured Mode) + +- [x] 2.1 Define a typed dataclass for the structured output covering user (name, handle, bio, profile_image_url), tweet (text, language, created_at), and media list (url, type, preview_image_url) +- [x] 2.2 Extend the X API request in `TwitterStatusFetcher` to include `profile_image_url` in `user.fields`, `created_at` in `tweet.fields`, and `preview_image_url` in `media.fields` +- [x] 2.3 Extract the API call + raw-dict caching into a private `__fetch_raw()` method, keyed under `twitter-status-fetcher-structured` +- [x] 2.4 Add public `as_structured()` method that returns the typed dataclass without invoking computer vision +- [x] 2.5 Add public `as_text()` method preserving the existing `execute()` text-formatting behavior (including CV photo descriptions); keep its existing cache untouched +- [x] 2.6 Update existing call sites (e.g., `WebFetcher`) to call `as_text()` instead of `execute()`; keep `execute()` as a thin alias for `as_text()` to avoid churn, OR update directly if call sites are few +- [x] 2.7 Add tests covering structured mode (with mocked X API response containing photos, GIF preview, and video preview) and verifying the structured cache key prefix + +## 3. Photo Download and Theme Extraction + +- [x] 3.1 Create `src/features/social_cards/photo_downloader.py` that downloads a list of image URLs (profile + media) to in-memory bytes; include bearer auth header support and verify on first integration test whether X CDN URLs require it +- [x] 3.2 Create `src/features/social_cards/theme.py` with a `pick_theme(profile_bytes, media_bytes_list, brand_default) -> ThemeColors` function implementing the three-tier fallback (profile dominant โ†’ combined media dominant โ†’ brand default) +- [x] 3.3 Implement dominant-color extraction using Pillow's `Image.quantize()` against a downsized image +- [x] 3.4 Implement gradient second-color derivation: ยฑ10% lightness shift away from neutral, ยฑ10ยฐ hue rotation toward red/blue +- [x] 3.5 Implement contrast-based foreground color choice (black or white) against the primary +- [x] 3.6 Add tests covering theme extraction priority, edge cases (no profile, no media, all-grayscale image), and gradient derivation + +## 4. SVG Card Renderer + +- [x] 4.1 Create `src/features/social_cards/card_layout.py` with constants for card width per aspect ratio (1080 / 1620), header height, padding, font sizes, and the photo-grid orientation rule +- [x] 4.2 Create `src/features/social_cards/card_template.py` (or a Jinja2 `.svg.j2` file) producing the full SVG: gradient background, header (circular avatar via ``, name+handle, datetime, agent logo), tweet text via `` with manual word-wrap, photo grid with selective corner rounding, and footer (X icon + shortened URL at low opacity) +- [x] 4.3 Implement word-wrap logic: measure text width per `` and split tweet text into lines that fit the body width; expand the SVG `viewBox` height as the line count grows +- [x] 4.4 Implement photo-grid layout: portrait card โ†’ 1 col; landscape/square card โ†’ 2 col, downgraded to 1 col when โ‰ฅ50% of photos are landscape +- [x] 4.5 Implement selective corner rounding using SVG `path` with per-corner radii (top corners on first row, bottom corners on last row) +- [x] 4.6 Embed photo bytes inline in the SVG as `data:image/...;base64,...` `` href values +- [x] 4.7 Format the datetime as `YYYY-MM-DD ยท UTC h:mm AM/PM` from the X `created_at` ISO-8601 string +- [x] 4.8 Create `src/features/social_cards/card_renderer.py` with a `render(structured_data, theme, aspect_ratio) -> bytes` function that builds the SVG and rasterizes via `resvg-py`, ensuring the Heebo TTF is registered with the resvg font database +- [x] 4.9 Add unit tests for word-wrap, grid orientation rule, datetime formatting, and SVG-string snapshot of a known-input card + +## 5. Renderer Orchestration + +- [x] 5.1 Create `src/features/social_cards/social_card_orchestrator.py` that wires together: tweet ID resolution (`resolve_tweet_id`), structured fetch (`TwitterStatusFetcher.as_structured`), profile-image URL transform (`_normal` โ†’ `_bigger`), photo downloads, theme selection, URL shortening (`UrlShortener`), SVG render, and image upload (`ImageUploader`) +- [x] 5.2 Implement structured error handling: each failure point raises a `social_cards`-scoped error from `util.errors` with appropriate `util.error_codes`; the LLM tool layer surfaces these as JSON errors +- [x] 5.3 Add DI factory methods in `src/di/di.py` for `photo_downloader`, `social_card_orchestrator`, and any other new components +- [x] 5.4 Add tests covering the orchestrator's happy path, X API error, photo download error, render error, and upload error + +## 6. LLM Tool Wiring + +- [x] 6.1 Add `render_social_post(di, url, aspect_ratio)` function in `src/features/chat/llm_tools/llm_tool_library.py` with a generic docstring suitable for the LLM (e.g., "Renders a social network post (e.g., Twitter/X) into a styled, shareable card image. Returns the URL of the rendered image.") +- [x] 6.2 Default `aspect_ratio` to `"2:3"` and validate against the allowed set `{"1:1", "2:3", "3:2"}`; on invalid value, return error JSON +- [x] 6.3 Resolve the configured X API tool via `tool_choice_resolver.require_tool(ToolType.api_twitter, ...)` and pass it through to the orchestrator +- [x] 6.4 Register the new tool in `LLMToolLibrary.ALL_LLM_TOOLS` +- [x] 6.5 Add tests covering: success returns image URL in `__success({...})`, invalid URL returns error, invalid aspect_ratio returns error, downstream failure surfaces as error JSON + +## 7. Documentation and Verification + +- [x] 7.1 Update `docs/` with the new LLM tool entry (input args, return shape, example) +- [x] 7.2 Run `pipenv run pre-commit run --all-files --show-diff-on-failure` and resolve any lint issues +- [x] 7.3 Run the full test suite to confirm no regressions +- [x] 7.4 Manual end-to-end verification: invoke the tool against a real public tweet with photos and inspect the resulting card; verify against a tweet with a GIF, a video, and a no-media tweet diff --git a/pyproject.toml b/pyproject.toml index c42a8d62..9bfca5be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.11.2" +version = "5.12.0" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/assets/fonts/Heebo-Variable.ttf b/src/assets/fonts/Heebo-Variable.ttf new file mode 100644 index 00000000..acc085c6 Binary files /dev/null and b/src/assets/fonts/Heebo-Variable.ttf differ diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt new file mode 100644 index 00000000..bf178468 --- /dev/null +++ b/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Heebo Project Authors (https://github.com/OdedEzer/heebo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/di/di.py b/src/di/di.py index 3faba5f7..ed95d1d9 100644 --- a/src/di/di.py +++ b/src/di/di.py @@ -84,10 +84,12 @@ from features.images.simple_image_generator import SimpleImageGenerator from features.images.smart_image_generator import SmartImageGenerator from features.integrations.platform_bot_sdk import PlatformBotSDK + from features.social_cards.social_card_orchestrator import SocialCardOrchestrator from features.sponsorships.sponsorship_service import SponsorshipService from features.support.user_support_service import UserSupportService from features.web_browsing.ai_web_search import AIWebSearch from features.web_browsing.html_content_cleaner import HTMLContentCleaner + from features.web_browsing.photo_downloader import PhotoDownloader from features.web_browsing.twitter_status_fetcher import TwitterStatusFetcher from features.web_browsing.url_shortener import UrlShortener from features.web_browsing.web_fetcher import WebFetcher @@ -814,6 +816,16 @@ def twitter_status_fetcher( from features.web_browsing.twitter_status_fetcher import TwitterStatusFetcher return TwitterStatusFetcher(tweet_id, x_api_tool, vision_tool, self) + # noinspection PyMethodMayBeStatic + def photo_downloader(self, bearer_token: str | None = None) -> "PhotoDownloader": + from features.web_browsing.photo_downloader import PhotoDownloader + return PhotoDownloader(bearer_token = bearer_token) + + # noinspection PyMethodMayBeStatic + def social_card_orchestrator(self, x_api_tool: ConfiguredTool) -> "SocialCardOrchestrator": + from features.social_cards.social_card_orchestrator import SocialCardOrchestrator + return SocialCardOrchestrator(x_api_tool, self) + def url_shortener( self, long_url: str, diff --git a/src/features/chat/chat_image_edit_service.py b/src/features/chat/chat_image_edit_service.py index 9b073f98..3dc60758 100644 --- a/src/features/chat/chat_image_edit_service.py +++ b/src/features/chat/chat_image_edit_service.py @@ -104,7 +104,6 @@ def execute(self) -> tuple[Result, list[dict[str, str | None]]]: media_mode = invoker_chat.media_mode, chat_id = external_id, photo_url = image_url, - caption = "๐Ÿ“ธ", thumbnail = image_url, ) log.t("Image edited and sent successfully") diff --git a/src/features/chat/llm_tools/llm_tool_library.py b/src/features/chat/llm_tools/llm_tool_library.py index e86422f2..2d8624ae 100644 --- a/src/features/chat/llm_tools/llm_tool_library.py +++ b/src/features/chat/llm_tools/llm_tool_library.py @@ -18,6 +18,7 @@ from features.external_tools.intelligence_presets import default_tool_for from features.images.smart_image_generator import SmartImageGenerator from features.integrations.integrations import add_messaging_frequency_warning, resolve_private_chat_id +from features.social_cards.social_card_orchestrator import SocialCardOrchestrator from features.support.user_support_service import UserSupportService from features.web_browsing.ai_web_search import AIWebSearch from util import log @@ -467,6 +468,30 @@ def transfer_credits_to_user( return __error(e) +def render_social_post(di: DI, url: str) -> str: + """ + Used for extracting and persisting the agent's screenshot/render of a social post. + Renders a social network post into a styled, shareable card image. + Supports only X/Twitter links for now. + + Args: + url: [mandatory] The URL of the social media post to render, starting with 'http://' or 'https://' + """ + try: + x_api_tool = di.tool_choice_resolver.require_tool(SocialCardOrchestrator.TOOL_TYPE, default_tool_for(SocialCardOrchestrator.TOOL_TYPE)) + image_url = di.social_card_orchestrator(x_api_tool).execute(url) + invoker_chat = di.require_invoker_chat() + di.platform_bot_sdk().smart_send_photo( + media_mode = invoker_chat.media_mode, + chat_id = int(invoker_chat.external_id or "-1"), + photo_url = image_url, + thumbnail = image_url, + ) + return __success({"next_step": "Confirm to the user that the card has been rendered and sent"}) + except Exception as e: + return __error(e) + + # === Helper functions === @@ -511,6 +536,7 @@ def __error(message: str | Exception) -> str: "connect_profiles": connect_profiles, "check_usage_and_balance": check_usage_and_balance, "transfer_credits_to_user": transfer_credits_to_user, + "render_social_post": render_social_post, } diff --git a/src/features/images/smart_image_generator.py b/src/features/images/smart_image_generator.py index 70bbdbc1..0b4fc662 100644 --- a/src/features/images/smart_image_generator.py +++ b/src/features/images/smart_image_generator.py @@ -95,7 +95,6 @@ def execute(self) -> Result: media_mode = invoker_chat.media_mode, chat_id = int(invoker_chat.external_id or "-1"), photo_url = image_url, - caption = "๐Ÿ“ธ", thumbnail = image_url, ) except Exception as e: diff --git a/src/features/social_cards/brand.py b/src/features/social_cards/brand.py new file mode 100644 index 00000000..31cea191 --- /dev/null +++ b/src/features/social_cards/brand.py @@ -0,0 +1,2 @@ +BRAND_GRADIENT_START = "#251A3D" +BRAND_GRADIENT_END = "#040b19" diff --git a/src/features/social_cards/card_layout.py b/src/features/social_cards/card_layout.py new file mode 100644 index 00000000..1bd5c37a --- /dev/null +++ b/src/features/social_cards/card_layout.py @@ -0,0 +1,30 @@ +CARD_OUTER_PAD = 24 # transparent shadow margin around card +CARD_CORNER_RADIUS = 40 +CARD_INNER_PAD = 54 # padding inside the card +CARD_SECTION_GAP = 28 # vertical gap between header / body / photos / footer +PHOTO_CORNER_RADIUS = 24 +PHOTO_GAP = 4 # gap between stacked photos +AVATAR_SIZE = 64 +AVATAR_GAP = 16 # gap between avatar and name column +LOGO_SIZE = 40 +X_ICON_SIZE = 12 # footer X/Twitter icon +HEADER_HEIGHT = AVATAR_SIZE + CARD_INNER_PAD +DIVIDER_OPACITY = 0.2 +FOOTER_OPACITY = 0.45 +FONT_SIZE_NAME = 20 +FONT_SIZE_DATE = 15 +FONT_SIZE_BODY = 22 +FONT_SIZE_FOOTER = 14 +LINE_HEIGHT_BODY = 32 # pixels per line in body text +DROP_SHADOW_BLUR = 10 +DROP_SHADOW_DY = 6 +DROP_SHADOW_OPACITY = 0.30 + + +def card_width_from_text(text: str) -> int: + length = len(text) + if length <= 500: + return 800 + if length <= 1000: + return 1000 + return 1200 diff --git a/src/features/social_cards/card_renderer.py b/src/features/social_cards/card_renderer.py new file mode 100644 index 00000000..dda9da60 --- /dev/null +++ b/src/features/social_cards/card_renderer.py @@ -0,0 +1,31 @@ +import resvg_py + +from features.social_cards.card_layout import card_width_from_text +from features.social_cards.card_template import build_svg +from features.social_cards.theme import ThemeColors +from features.web_browsing.twitter_status_fetcher import TweetData +from util.config import config + + +def render( + tweet: TweetData, + theme: ThemeColors, + profile_bytes: bytes | None = None, + media_bytes: list[bytes] | None = None, + short_url: str | None = None, +) -> bytes: + media = media_bytes or [] + card_width = card_width_from_text(tweet.text) + svg = build_svg( + tweet = tweet, + theme = theme, + card_width = card_width, + profile_bytes = profile_bytes, + media_bytes = media, + short_url = short_url, + ) + return resvg_py.svg_to_bytes( + svg_string = svg, + font_files = [config.font_path], + skip_system_fonts = True, + ) diff --git a/src/features/social_cards/card_template.py b/src/features/social_cards/card_template.py new file mode 100644 index 00000000..79a97df2 --- /dev/null +++ b/src/features/social_cards/card_template.py @@ -0,0 +1,384 @@ +import base64 +import io +import re +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from PIL import Image, ImageFont + +from features.social_cards.card_layout import ( + AVATAR_GAP, + AVATAR_SIZE, + CARD_CORNER_RADIUS, + CARD_INNER_PAD, + CARD_OUTER_PAD, + CARD_SECTION_GAP, + DIVIDER_OPACITY, + DROP_SHADOW_BLUR, + DROP_SHADOW_DY, + DROP_SHADOW_OPACITY, + FONT_SIZE_BODY, + FONT_SIZE_DATE, + FONT_SIZE_FOOTER, + FONT_SIZE_NAME, + FOOTER_OPACITY, + LINE_HEIGHT_BODY, + LOGO_SIZE, + PHOTO_CORNER_RADIUS, + PHOTO_GAP, + X_ICON_SIZE, +) +from features.social_cards.theme import ThemeColors +from features.web_browsing.twitter_status_fetcher import TweetData +from util.config import config + +_FONT_PATH = Path(config.font_path) +_FONT_NAME = "Heebo" + +_FONT_B64: str | None = None +_LOGO_CACHE: dict[str, bytes] = {} + +_SPECIAL_TOKEN_RE = re.compile(r"(https?://\S+|www\.\S+|@\w+|#\w+|\$[A-Za-z]+)") + + +def _font_b64() -> str: + global _FONT_B64 + if _FONT_B64 is None: + _FONT_B64 = base64.b64encode(_FONT_PATH.read_bytes()).decode("ascii") + return _FONT_B64 + + +def _b64_image(data: bytes, mime: str = "image/jpeg") -> str: + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + + +def _fetch_logo(key: str) -> bytes: + if key not in _LOGO_CACHE: + url = config.logos[key] + with urllib.request.urlopen(url) as response: + _LOGO_CACHE[key] = response.read() + return _LOGO_CACHE[key] + + +def _logo_svg_b64(key: str) -> str: + return f"data:image/svg+xml;base64,{base64.b64encode(_fetch_logo(key)).decode('ascii')}" + + +def _agent_logo_key(theme: ThemeColors) -> str: + hex_color = theme.gradient_start.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + if luminance < 0.3: + return "agent_logo_light" + if luminance > 0.7: + return "agent_logo_dark" + return "agent_logo_color" + + +def _x_logo_key(theme: ThemeColors) -> str: + return "x_logo_light" if theme.text_color == "#ffffff" else "x_logo_dark" + + +def _accent_color(theme: ThemeColors) -> str: + h = theme.gradient_start.lstrip("#") + r, g, b = 255 - int(h[0:2], 16), 255 - int(h[2:4], 16), 255 - int(h[4:6], 16) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _image_mime(data: bytes) -> str: + try: + img = Image.open(io.BytesIO(data)) + fmt = (img.format or "JPEG").upper() + return {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}.get(fmt, "image/jpeg") + except Exception: + return "image/jpeg" + + +def _photo_natural_height(data: bytes, display_w: int) -> int: + try: + img = Image.open(io.BytesIO(data)) + if img.width == 0: + return display_w + return round(display_w * img.height / img.width) + except Exception: + return display_w + + +def _photo_sort_key(data: bytes) -> int: + try: + img = Image.open(io.BytesIO(data)) + if img.height > img.width * 1.05: + return 0 # portrait first + if img.width > img.height * 1.05: + return 2 # landscape last + return 1 # square middle + except Exception: + return 1 + + +def _pillow_font(size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(str(_FONT_PATH), size) + + +def _text_width(text: str, size: int) -> int: + font = _pillow_font(size) + bbox = font.getbbox(text) + return bbox[2] - bbox[0] + + +def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: + lines: list[str] = [] + for paragraph in text.splitlines(): + if not paragraph.strip(): + lines.append("") + continue + words = paragraph.split(" ") + current = "" + for word in words: + candidate = (current + " " + word).strip() + if _text_width(candidate, font_size) <= max_width: + current = candidate + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines or [""] + + +def _line_tspans(line: str, body_x: int, dy: int, normal_fill: str, accent: str) -> str: + parts = _SPECIAL_TOKEN_RE.split(line) + spans = [] + is_first = True + for i, part in enumerate(parts): + if not part: + continue + is_special = i % 2 == 1 + fill = accent if is_special else normal_fill + opacity = ' fill-opacity="0.8"' if is_special else "" + pos = f' x="{body_x}" dy="{dy}"' if is_first else "" + spans.append(f'{_escape(part)}') + is_first = False + if not spans: + return f' ' + return "".join(spans) + + +def _format_datetime(created_at: str | None) -> str: + if not created_at: + return "" + try: + dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(timezone.utc) + hour = dt.hour % 12 or 12 + am_pm = "AM" if dt.hour < 12 else "PM" + return f"{dt.year}-{dt.month:02d}-{dt.day:02d} ยท UTC {hour}:{dt.minute:02d} {am_pm}" + except Exception: + return created_at + + +def _rounded_rect_path(x: int, y: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> str: + return ( + f"M {x + tl},{y} " + f"H {x + w - tr} " + f"Q {x + w},{y} {x + w},{y + tr} " + f"V {y + h - br} " + f"Q {x + w},{y + h} {x + w - br},{y + h} " + f"H {x + bl} " + f"Q {x},{y + h} {x},{y + h - bl} " + f"V {y + tl} " + f"Q {x},{y} {x + tl},{y} Z" + ) + + +def _photo_cell_parts( + cell_id: str, + x: int, + y: int, + w: int, + h: int, + photo_b64: str, + tl: int, + tr: int, + br: int, + bl: int, +) -> tuple[str, str]: + path = _rounded_rect_path(x, y, w, h, tl, tr, br, bl) + clip = f'' + img = ( + f'' + ) + return clip, img + + +def build_svg( + tweet: TweetData, + theme: ThemeColors, + card_width: int, + profile_bytes: bytes | None, + media_bytes: list[bytes], + short_url: str | None, +) -> str: + cx = CARD_OUTER_PAD # card left edge + inner_w = card_width - 2 * CARD_INNER_PAD + body_x = cx + CARD_INNER_PAD + r = CARD_CORNER_RADIUS + accent = _accent_color(theme) + + defs: list[str] = [] + content: list[str] = [] + + # Font + defs.append( + f'", + ) + + # Background gradient + defs.append( + f'' + f'' + f'' + f"", + ) + + # Drop shadow filter + defs.append( + f'' + f'' + f"", + ) + + # Avatar clip + av_cx = cx + CARD_INNER_PAD + AVATAR_SIZE // 2 + av_cy_center = CARD_OUTER_PAD + CARD_INNER_PAD + AVATAR_SIZE // 2 + defs.append(f'') + + # Current Y cursor (inside SVG coords, card top = CARD_OUTER_PAD) + y = CARD_OUTER_PAD + CARD_INNER_PAD + + # Header + if profile_bytes: + avatar_b64 = _b64_image(profile_bytes, _image_mime(profile_bytes)) + content.append( + f'', + ) + else: + initial = (tweet.user.handle or "?")[0].upper() + content.append( + f'' + f'{initial}', + ) + + name_x = cx + CARD_INNER_PAD + AVATAR_SIZE + AVATAR_GAP + _name_date_span = FONT_SIZE_DATE + 8 + _visual_block_h = FONT_SIZE_NAME + _name_date_span + name_y = y + (AVATAR_SIZE + _visual_block_h) // 2 - _name_date_span + date_y = name_y + _name_date_span + if tweet.user.name: + name_spans = ( + f'{_escape(tweet.user.name)}' + f' (@{_escape(tweet.user.handle)})' + ) + else: + name_spans = f'@{_escape(tweet.user.handle)}' + content.append( + f'{name_spans}', + ) + dt_str = _format_datetime(tweet.created_at) + if dt_str: + content.append( + f'{dt_str}', + ) + + # Agent logo (top-right) + logo_x = cx + card_width - CARD_INNER_PAD - LOGO_SIZE + logo_y = y + (AVATAR_SIZE - LOGO_SIZE) // 2 + logo_key = _agent_logo_key(theme) + logo_b64 = _logo_svg_b64(logo_key) + logo_opacity = ' opacity="0.8"' if logo_key != "agent_logo_color" else "" + content.append( + f'', + ) + + y += AVATAR_SIZE + CARD_SECTION_GAP + + # Divider + content.append( + f'', + ) + y += CARD_SECTION_GAP + + # Tweet body with colored tokens + lines = _word_wrap(tweet.text, inner_w, FONT_SIZE_BODY) + if lines: + tspans = "".join( + _line_tspans(ln, body_x, 0 if i == 0 else LINE_HEIGHT_BODY, theme.text_color, accent) for i, ln in enumerate(lines) + ) + content.append( + f'' + f"{tspans}", + ) + y += len(lines) * LINE_HEIGHT_BODY + CARD_SECTION_GAP + + # Photos โ€” sorted portrait โ†’ square โ†’ landscape, natural height, single column + if media_bytes: + sorted_media = sorted(media_bytes, key = _photo_sort_key) + total = len(sorted_media) + for idx, photo_data in enumerate(sorted_media): + is_first = idx == 0 + is_last = idx == total - 1 + ph = _photo_natural_height(photo_data, inner_w) + tl = tr = PHOTO_CORNER_RADIUS if is_first else 2 + bl = br = PHOTO_CORNER_RADIUS if is_last else 2 + cell_id = f"photo-{idx}" + b64 = _b64_image(photo_data, _image_mime(photo_data)) + cell_clip, cell_img = _photo_cell_parts(cell_id, body_x, y, inner_w, ph, b64, tl, tr, br, bl) + defs.append(cell_clip) + content.append(cell_img) + y += ph + (PHOTO_GAP if not is_last else 0) + y += CARD_SECTION_GAP + + # Footer โ€” align icon center to text cap-height center + footer_y = y + FONT_SIZE_FOOTER + icon_y = round(footer_y - (FONT_SIZE_FOOTER * 0.65 + X_ICON_SIZE) / 2) + x_logo_b64 = _logo_svg_b64(_x_logo_key(theme)) + content.append( + f'', + ) + if short_url: + display_url = short_url.removeprefix("https://").removeprefix("http://") + content.append( + f'{_escape(display_url)}', + ) + y += FONT_SIZE_FOOTER + CARD_INNER_PAD + + total_h = y + CARD_OUTER_PAD + card_h = total_h - 2 * CARD_OUTER_PAD + svg_w = card_width + 2 * CARD_OUTER_PAD + + card_rect = ( + f'' + ) + + defs_svg = "" + "".join(defs) + "" + content_svg = card_rect + "".join(content) + return f'{defs_svg}{content_svg}' + + +def _escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) diff --git a/src/features/social_cards/social_card_orchestrator.py b/src/features/social_cards/social_card_orchestrator.py new file mode 100644 index 00000000..194134fa --- /dev/null +++ b/src/features/social_cards/social_card_orchestrator.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +from di.di import DI +from features.external_tools.configured_tool import ConfiguredTool +from features.external_tools.external_tool import ToolType +from features.social_cards import card_renderer +from features.social_cards.theme import pick_theme +from features.web_browsing.photo_downloader import PhotoDownloader +from features.web_browsing.twitter_utils import resolve_tweet_id +from util import log +from util.error_codes import IMAGE_GENERATION_FAILED, WEB_FETCH_FAILED +from util.errors import ExternalServiceError, ValidationError + + +class SocialCardOrchestrator: + + TOOL_TYPE: ToolType = ToolType.api_twitter + + __x_api_tool: ConfiguredTool + __di: DI + + def __init__(self, x_api_tool: ConfiguredTool, di: DI): + self.__x_api_tool = x_api_tool + self.__di = di + + def execute(self, url: str) -> str: + tweet_id = resolve_tweet_id(url) + if not tweet_id: + raise ValidationError(f"Cannot resolve tweet ID from URL: {url}", WEB_FETCH_FAILED) + + fetcher = self.__di.twitter_status_fetcher(tweet_id, self.__x_api_tool, self.__x_api_tool) + tweet = fetcher.as_structured() + + downloader = PhotoDownloader() + + profile_bytes: bytes | None = None + if tweet.user.profile_image_url: + bigger_url = tweet.user.profile_image_url.replace("_normal", "_bigger") + profile_bytes = downloader.download(bigger_url) + + media_urls = [m.url or m.preview_url for m in tweet.media if m.url or m.preview_url] + media_bytes = downloader.download_many([u for u in media_urls if u]) + + theme = pick_theme(profile_bytes, media_bytes) + + short_url: str | None = None + try: + valid_until = datetime.now() + timedelta(days = 365) + short_url = self.__di.url_shortener(url, valid_until = valid_until).execute() + except Exception as e: + log.w("URL shortening failed, using original URL", e) + short_url = url + + try: + png_bytes = card_renderer.render( + tweet = tweet, + theme = theme, + profile_bytes = profile_bytes, + media_bytes = media_bytes, + short_url = short_url, + ) + except Exception as e: + raise ExternalServiceError("Card rendering failed", IMAGE_GENERATION_FAILED) from e + + return self.__di.image_uploader(binary_image = png_bytes).execute() diff --git a/src/features/social_cards/theme.py b/src/features/social_cards/theme.py new file mode 100644 index 00000000..82ac8800 --- /dev/null +++ b/src/features/social_cards/theme.py @@ -0,0 +1,116 @@ +import colorsys +import io +from dataclasses import dataclass + +from PIL import Image + +from features.social_cards.brand import BRAND_GRADIENT_END, BRAND_GRADIENT_START +from util import log + + +@dataclass(frozen = True) +class ThemeColors: + gradient_start: str # hex #RRGGBB + gradient_end: str # hex #RRGGBB + text_color: str # "#000000" or "#ffffff" + + +def pick_theme( + profile_bytes: bytes | None, + media_bytes_list: list[bytes], +) -> ThemeColors: + primary = _dominant_from_combined(media_bytes_list) if media_bytes_list else None + if primary is None: + primary = _dominant_from_bytes(profile_bytes) + if primary is None: + return ThemeColors( + gradient_start = BRAND_GRADIENT_START, + gradient_end = BRAND_GRADIENT_END, + text_color = "#ffffff", + ) + secondary = _derive_gradient_end(primary) + text_color = _contrast_text(primary) + light, dark = (primary, secondary) if sum(primary) >= sum(secondary) else (secondary, primary) + return ThemeColors( + gradient_start = _rgb_to_hex(light), + gradient_end = _rgb_to_hex(dark), + text_color = text_color, + ) + + +def _dominant_from_bytes(data: bytes | None) -> tuple[int, int, int] | None: + if not data: + return None + try: + img = Image.open(io.BytesIO(data)).convert("RGB") + img = img.resize((64, 64)) + quantized = img.quantize(colors = 8) + palette = quantized.getpalette() + if not palette: + return None + best_rgb = _most_saturated_from_palette(palette, 8) + return best_rgb + except Exception as e: + log.w("Dominant color extraction failed", e) + return None + + +def _dominant_from_combined(images: list[bytes]) -> tuple[int, int, int] | None: + try: + strips = [] + for data in images: + try: + img = Image.open(io.BytesIO(data)).convert("RGB").resize((32, 32)) + strips.append(img) + except Exception: + continue + if not strips: + return None + combined_w = 32 * len(strips) + canvas = Image.new("RGB", (combined_w, 32)) + for i, strip in enumerate(strips): + canvas.paste(strip, (i * 32, 0)) + quantized = canvas.quantize(colors = 8) + palette = quantized.getpalette() + if not palette: + return None + return _most_saturated_from_palette(palette, 8) + except Exception as e: + log.w("Combined dominant color extraction failed", e) + return None + + +def _most_saturated_from_palette(palette: list[int], count: int) -> tuple[int, int, int]: + best_rgb = (128, 128, 128) + best_sat = -1.0 + actual_count = min(count, len(palette) // 3) + for i in range(actual_count): + r, g, b = palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2] + _, s, _ = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) + if s > best_sat: + best_sat = s + best_rgb = (r, g, b) + return best_rgb + + +def _derive_gradient_end(rgb: tuple[int, int, int]) -> tuple[int, int, int]: + r, g, b = rgb + h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) + lightness = (r + g + b) / (3 * 255) + v_shift = 0.2 if lightness >= 0.5 else -0.2 + new_v = max(0.0, min(1.0, v + v_shift)) + # hue shift: toward red (0ยฐ) if closer to blue side, toward blue if closer to red + hue_shift = 10 / 360 + new_h = (h + hue_shift) % 1.0 + nr, ng, nb = colorsys.hsv_to_rgb(new_h, s, new_v) + return (round(nr * 255), round(ng * 255), round(nb * 255)) + + +def _contrast_text(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return "#000000" if luminance > 0.5 else "#ffffff" + + +def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: + return "#{:02x}{:02x}{:02x}".format(*rgb) diff --git a/src/features/web_browsing/photo_downloader.py b/src/features/web_browsing/photo_downloader.py new file mode 100644 index 00000000..7999166f --- /dev/null +++ b/src/features/web_browsing/photo_downloader.py @@ -0,0 +1,40 @@ +import requests + +from util import log +from util.config import config +from util.error_codes import WEB_FETCH_FAILED +from util.errors import ExternalServiceError + + +class PhotoDownloader: + + __bearer_token: str | None + + def __init__(self, bearer_token: str | None = None): + self.__bearer_token = bearer_token + + def download(self, url: str) -> bytes | None: + try: + headers = {"User-Agent": "Mozilla/5.0 (compatible; AppifyHub-Agent/1.0)"} + if self.__bearer_token: + headers["Authorization"] = f"Bearer {self.__bearer_token}" + response = requests.get(url, headers = headers, timeout = config.web_timeout_s) + response.raise_for_status() + return response.content + except Exception as e: + log.w(f"Failed to download photo from {url}", e) + return None + + def download_many(self, urls: list[str]) -> list[bytes]: + results = [] + for url in urls: + data = self.download(url) + if data: + results.append(data) + return results + + def require(self, url: str) -> bytes: + data = self.download(url) + if not data: + raise ExternalServiceError(f"Failed to download required photo: {url}", WEB_FETCH_FAILED) + return data diff --git a/src/features/web_browsing/twitter_status_fetcher.py b/src/features/web_browsing/twitter_status_fetcher.py index 365fd880..06f939e6 100644 --- a/src/features/web_browsing/twitter_status_fetcher.py +++ b/src/features/web_browsing/twitter_status_fetcher.py @@ -1,6 +1,8 @@ +import json +from dataclasses import dataclass, field from datetime import datetime, timedelta from time import sleep -from typing import Any, Dict +from typing import Any from db.schema.tools_cache import ToolsCache, ToolsCacheSave from di.di import DI @@ -14,10 +16,35 @@ from util.errors import ExternalServiceError CACHE_PREFIX = "twitter-status-fetcher" -CACHE_TTL = timedelta(weeks = 52) +CACHE_PREFIX_STRUCTURED = "twitter-status-fetcher-json" +CACHE_TTL = timedelta(weeks = 1) RATE_LIMIT_DELAY_S = 2 +@dataclass +class TweetMediaItem: + url: str | None + preview_url: str | None + media_type: str # "photo", "animated_gif", "video" + + +@dataclass +class TweetUserData: + name: str | None + handle: str + bio: str | None + profile_image_url: str | None + + +@dataclass +class TweetData: + user: TweetUserData + text: str + language: str | None + created_at: str | None + media: list[TweetMediaItem] = field(default_factory = list) + + class TwitterStatusFetcher: TWITTER_TOOL_TYPE: ToolType = ToolType.api_twitter @@ -43,12 +70,36 @@ def __init__( self.__di = di def execute(self) -> str: - log.t(f"Fetching content for tweet ID: {self.__tweet_id}") + return self.as_text() + + def as_text(self) -> str: + log.t(f"Fetching text content for tweet ID: {self.__tweet_id}") + text_cache_key = self.__di.tools_cache_crud.create_key(CACHE_PREFIX, self.__tweet_id) + cached = self.__get_cached_string(text_cache_key) + if cached: + return cached + raw = self.__fetch_raw() + resolved = self.__resolve_content(raw) + self.__di.tools_cache_crud.save( + ToolsCacheSave( + key = text_cache_key, + value = resolved, + expires_at = datetime.now() + CACHE_TTL, + ), + ) + log.t(f"Text cache updated for key '{text_cache_key}'") + return resolved + + def as_structured(self) -> TweetData: + log.t(f"Fetching structured data for tweet ID: {self.__tweet_id}") + raw = self.__fetch_raw() + return self.__parse_structured(raw) - cache_key = self.__di.tools_cache_crud.create_key(CACHE_PREFIX, self.__tweet_id) - cached_content = self.__get_cached_content(cache_key) - if cached_content: - return cached_content + def __fetch_raw(self) -> dict[str, Any]: + raw_cache_key = self.__di.tools_cache_crud.create_key(CACHE_PREFIX_STRUCTURED, self.__tweet_id) + cached_json = self.__get_cached_string(raw_cache_key) + if cached_json: + return json.loads(cached_json) api_url = f"https://api.x.com/2/tweets/{self.__tweet_id}" headers = { @@ -56,9 +107,9 @@ def execute(self) -> str: } params = { "expansions": "author_id,attachments.media_keys", - "user.fields": "name,username,description", - "tweet.fields": "lang,text", - "media.fields": "url,type", + "user.fields": "name,username,description,profile_image_url", + "tweet.fields": "lang,text,created_at,note_tweet", + "media.fields": "url,type,preview_image_url", } sleep(RATE_LIMIT_DELAY_S) @@ -66,20 +117,18 @@ def execute(self) -> str: response.raise_for_status() response_json = response.json() or {} - resolved_content = self.__resolve_content(response_json) self.__di.tools_cache_crud.save( ToolsCacheSave( - key = cache_key, - value = resolved_content, + key = raw_cache_key, + value = json.dumps(response_json), expires_at = datetime.now() + CACHE_TTL, ), ) - log.t(f"Cache updated for key '{cache_key}'") + log.t(f"Raw cache updated for key '{raw_cache_key}'") + return response_json - return resolved_content - - def __get_cached_content(self, cache_key: str) -> str | None: - log.t(f"Fetching cached content for key: '{cache_key}'") + def __get_cached_string(self, cache_key: str) -> str | None: + log.t(f"Checking cache for key: '{cache_key}'") cache_entry_db = self.__di.tools_cache_crud.get(cache_key) if cache_entry_db: cache_entry = ToolsCache.model_validate(cache_entry_db) @@ -90,13 +139,50 @@ def __get_cached_content(self, cache_key: str) -> str | None: log.t(f"Cache miss for key '{cache_key}'") return None - def __resolve_content(self, response: Dict[str, Any]) -> str: + def __parse_structured(self, response: dict[str, Any]) -> TweetData: + post_data = response.get("data") or {} + includes = response.get("includes") or {} + + users = includes.get("users") or [] + user_raw = users[0] if users else {} + + user = TweetUserData( + name = user_raw.get("name") or None, + handle = user_raw.get("username") or "unknown", + bio = user_raw.get("description") or None, + profile_image_url = user_raw.get("profile_image_url") or None, + ) + + media_items: list[TweetMediaItem] = [] + for m in includes.get("media") or []: + media_type = m.get("type") or "photo" + media_items.append( + TweetMediaItem( + url = m.get("url") or None, + preview_url = m.get("preview_image_url") or None, + media_type = media_type, + ), + ) + + note_tweet = post_data.get("note_tweet") or {} + text = note_tweet.get("text") or post_data.get("text") or "" + + return TweetData( + user = user, + text = text, + language = post_data.get("lang") or None, + created_at = post_data.get("created_at") or None, + media = media_items, + ) + + def __resolve_content(self, response: dict[str, Any]) -> str: try: post_data = response.get("data") or {} includes = response.get("includes") or {} post_language = post_data.get("lang") or "" - post_text = post_data.get("text") or "" + note_tweet = post_data.get("note_tweet") or {} + post_text = note_tweet.get("text") or post_data.get("text") or "" users = includes.get("users") or [] user = users[0] if users else {} @@ -121,7 +207,7 @@ def __resolve_content(self, response: Dict[str, Any]) -> str: except Exception as e: raise ExternalServiceError("Error formatting tweet content", EXTERNAL_EMPTY_RESPONSE) from e - def __resolve_photo_contents(self, includes: Dict[str, Any], additional_context: str | None) -> list[str]: + def __resolve_photo_contents(self, includes: dict[str, Any], additional_context: str | None) -> list[str]: log.t(f"Resolving photo contents for tweet {self.__tweet_id}") media_list = includes.get("media") or [] photo_descriptions: list[str] = [] @@ -131,9 +217,7 @@ def __resolve_photo_contents(self, includes: Dict[str, Any], additional_context: media_type = media.get("type") or None if url and media_type == "photo": extension = url.lower().split(".")[-1] - mime_type = ( - KNOWN_IMAGE_FORMATS.get(extension) if extension else KNOWN_IMAGE_FORMATS.get("png") - ) + mime_type = KNOWN_IMAGE_FORMATS.get(extension) if extension else KNOWN_IMAGE_FORMATS.get("png") analyzer = self.__di.computer_vision_analyzer( job_id = f"tweet-{self.__tweet_id}", image_mime_type = str(mime_type), diff --git a/src/util/config.py b/src/util/config.py index 576b494c..42811bd1 100644 --- a/src/util/config.py +++ b/src/util/config.py @@ -19,6 +19,7 @@ class ConfiguredProduct: url: str +# don't forget to cover in test_config.py class Config(metaclass = Singleton): DEV_API_KEY = "0000-1234-5678-0000" # needed for local dev mode @@ -67,6 +68,9 @@ class Config(metaclass = Singleton): usage_maintenance_fee_credits: float products_config_path: str products: dict[str, ConfiguredProduct] + logos_config_path: str + logos: dict[str, str] + font_path: str platform_open_ai_key: SecretStr platform_anthropic_key: SecretStr @@ -165,6 +169,8 @@ def __init__( def_version: str = "dev", def_usage_maintenance_fee_credits: float = 0.0, def_products_config_path: str = "config/products.yaml", + def_logos_config_path: str = "config/logos.yaml", + def_font_path: str = "src/assets/fonts/Heebo-Variable.ttf", def_platform_open_ai_key: SecretStr = SecretStr("invalid"), def_platform_anthropic_key: SecretStr = SecretStr("invalid"), def_platform_google_ai_key: SecretStr = SecretStr("invalid"), @@ -237,6 +243,9 @@ def __init__( self.usage_maintenance_fee_credits = float(self.__env("USAGE_MAINTENANCE_FEE_CREDITS", lambda: str(def_usage_maintenance_fee_credits))) self.products_config_path = self.__env("PRODUCTS_CONFIG_PATH", lambda: def_products_config_path) self.products = self.__load_products() + self.logos_config_path = self.__env("LOGOS_CONFIG_PATH", lambda: def_logos_config_path) + self.logos = self.__load_logos() + self.font_path = self.__env("FONT_PATH", lambda: def_font_path) self.__set_up_db(def_db_user, def_db_pass, def_db_host, def_db_name) self.api_key = self.__senv("API_KEY", lambda: def_api_key) @@ -293,5 +302,14 @@ def __load_products(self) -> dict[str, ConfiguredProduct]: logging.error(f"Failed to parse products config from '{self.products_config_path}': {e}") return {} + def __load_logos(self) -> dict[str, str]: + with open(self.logos_config_path) as f: + data: dict[str, any] = yaml.safe_load(f) + try: + return {k: v for k, v in data["logos"].items()} + except Exception as e: + logging.error(f"Failed to parse logos config from '{self.logos_config_path}': {e}") + return {} + config = Config() diff --git a/test/features/chat/test_chat_image_edit_service.py b/test/features/chat/test_chat_image_edit_service.py index b100d40f..da798f36 100644 --- a/test/features/chat/test_chat_image_edit_service.py +++ b/test/features/chat/test_chat_image_edit_service.py @@ -135,7 +135,6 @@ def test_execute_edit_image_success_single(self): media_mode = ChatConfigDB.MediaMode.all, chat_id = "test_chat_id", photo_url = "http://test.com/edited_image.png", - caption = "๐Ÿ“ธ", thumbnail = "http://test.com/edited_image.png", ) diff --git a/test/features/images/test_smart_image_generator.py b/test/features/images/test_smart_image_generator.py index f302945b..b99b5b24 100644 --- a/test/features/images/test_smart_image_generator.py +++ b/test/features/images/test_smart_image_generator.py @@ -86,7 +86,6 @@ def test_execute_success(self): media_mode = ChatConfigDB.MediaMode.all, chat_id = 1, photo_url = "http://example.com/image.png", - caption = "๐Ÿ“ธ", thumbnail = "http://example.com/image.png", ) diff --git a/test/features/social_cards/test_social_card_orchestrator.py b/test/features/social_cards/test_social_card_orchestrator.py new file mode 100644 index 00000000..3d4d6fb8 --- /dev/null +++ b/test/features/social_cards/test_social_card_orchestrator.py @@ -0,0 +1,233 @@ +import unittest +from unittest.mock import MagicMock, Mock, patch + +from pydantic import SecretStr + +from di.di import DI +from features.external_tools.configured_tool import ConfiguredTool +from features.social_cards.social_card_orchestrator import SocialCardOrchestrator +from features.web_browsing.twitter_status_fetcher import TweetData, TweetMediaItem, TweetUserData +from util.error_codes import IMAGE_GENERATION_FAILED, WEB_FETCH_FAILED +from util.errors import ExternalServiceError, ValidationError + + +def _make_tweet(with_media: bool = False) -> TweetData: + media = [ + TweetMediaItem(url = "https://pbs.twimg.com/media/abc.jpg", preview_url = None, media_type = "photo"), + ] if with_media else [] + return TweetData( + user = TweetUserData( + name = "Test User", + handle = "testuser", + bio = None, + profile_image_url = "https://pbs.twimg.com/profile_images/123/photo_normal.jpg", + ), + text = "Hello world", + language = "en", + created_at = "2026-05-04T12:00:00Z", + media = media, + ) + + +def _make_mock_di() -> DI: + di = Mock(spec = DI) + di.twitter_status_fetcher = MagicMock() + di.url_shortener = MagicMock() + di.image_uploader = MagicMock() + return di + + +def _make_x_api_tool() -> ConfiguredTool: + tool = MagicMock(spec = ConfiguredTool) + tool.token = SecretStr("fake-bearer-token") + return tool + + +class SocialCardOrchestratorTest(unittest.TestCase): + + mock_di: DI + mock_x_api_tool: ConfiguredTool + + def setUp(self): + self.mock_di = _make_mock_di() + self.mock_x_api_tool = _make_x_api_tool() + + def _make_orchestrator(self) -> SocialCardOrchestrator: + return SocialCardOrchestrator(self.mock_x_api_tool, self.mock_di) + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_happy_path_returns_image_url(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet() + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = b"profile-bytes" + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.return_value = "https://short.url/abc" + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.return_value = b"png-data" + + mock_uploader = MagicMock() + mock_uploader.execute.return_value = "https://cdn.example.com/card.png" + self.mock_di.image_uploader.return_value = mock_uploader + + result = self._make_orchestrator().execute("https://x.com/user/status/123456789") + + self.assertEqual(result, "https://cdn.example.com/card.png") + mock_renderer.render.assert_called_once() + + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_invalid_url_raises_validation_error(self, mock_resolve): + mock_resolve.return_value = None + + with self.assertRaises(ValidationError) as ctx: + self._make_orchestrator().execute("https://example.com/not-a-tweet") + + self.assertEqual(ctx.exception.error_code, WEB_FETCH_FAILED) + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_photo_download_failure_continues(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet(with_media = True) + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = None + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.return_value = "https://short.url/abc" + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.return_value = b"png-data" + + mock_uploader = MagicMock() + mock_uploader.execute.return_value = "https://cdn.example.com/card.png" + self.mock_di.image_uploader.return_value = mock_uploader + + result = self._make_orchestrator().execute("https://x.com/user/status/123456789") + self.assertEqual(result, "https://cdn.example.com/card.png") + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_render_failure_raises_external_service_error(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet() + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = None + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.return_value = "https://short.url/abc" + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.side_effect = RuntimeError("SVG rendering blew up") + + with self.assertRaises(ExternalServiceError) as ctx: + self._make_orchestrator().execute("https://x.com/user/status/123456789") + + self.assertEqual(ctx.exception.error_code, IMAGE_GENERATION_FAILED) + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_upload_failure_propagates(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet() + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = None + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.return_value = "https://short.url/abc" + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.return_value = b"png-data" + + mock_uploader = MagicMock() + mock_uploader.execute.side_effect = ExternalServiceError("imgbb is down", 5004) + self.mock_di.image_uploader.return_value = mock_uploader + + with self.assertRaises(ExternalServiceError): + self._make_orchestrator().execute("https://x.com/user/status/123456789") + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_url_shortener_failure_falls_back_to_original(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet() + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = None + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.side_effect = ExternalServiceError("shortener down", 5005) + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.return_value = b"png-data" + + mock_uploader = MagicMock() + mock_uploader.execute.return_value = "https://cdn.example.com/card.png" + self.mock_di.image_uploader.return_value = mock_uploader + + original_url = "https://x.com/user/status/123456789" + self._make_orchestrator().execute(original_url) + + _, kwargs = mock_renderer.render.call_args + self.assertEqual(kwargs["short_url"], original_url) + + @patch("features.social_cards.social_card_orchestrator.card_renderer") + @patch("features.social_cards.social_card_orchestrator.PhotoDownloader") + @patch("features.social_cards.social_card_orchestrator.resolve_tweet_id") + def test_profile_url_transform_normal_to_bigger(self, mock_resolve, mock_downloader_cls, mock_renderer): + mock_resolve.return_value = "123456789" + mock_fetcher = MagicMock() + mock_fetcher.as_structured.return_value = _make_tweet() + self.mock_di.twitter_status_fetcher.return_value = mock_fetcher + + mock_downloader = MagicMock() + mock_downloader.download.return_value = b"avatar" + mock_downloader.download_many.return_value = [] + mock_downloader_cls.return_value = mock_downloader + + mock_shortener = MagicMock() + mock_shortener.execute.return_value = "https://short.url/abc" + self.mock_di.url_shortener.return_value = mock_shortener + + mock_renderer.render.return_value = b"png-data" + + mock_uploader = MagicMock() + mock_uploader.execute.return_value = "https://cdn.example.com/card.png" + self.mock_di.image_uploader.return_value = mock_uploader + + self._make_orchestrator().execute("https://x.com/user/status/123456789") + + call_args = mock_downloader.download.call_args[0][0] + self.assertIn("_bigger", call_args) + self.assertNotIn("_normal", call_args) diff --git a/test/features/social_cards/test_theme.py b/test/features/social_cards/test_theme.py new file mode 100644 index 00000000..510f0298 --- /dev/null +++ b/test/features/social_cards/test_theme.py @@ -0,0 +1,94 @@ +import io +import unittest + +from PIL import Image + +from features.social_cards.brand import BRAND_GRADIENT_END, BRAND_GRADIENT_START +from features.social_cards.theme import ThemeColors, _contrast_text, _derive_gradient_end, _dominant_from_bytes, pick_theme + + +def _make_solid_png(r: int, g: int, b: int, size: int = 16) -> bytes: + img = Image.new("RGB", (size, size), color = (r, g, b)) + buf = io.BytesIO() + img.save(buf, format = "PNG") + return buf.getvalue() + + +class ThemePickerTest(unittest.TestCase): + + def test_falls_back_to_brand_when_no_images(self): + theme = pick_theme(None, []) + self.assertEqual(theme.gradient_start, BRAND_GRADIENT_START) + self.assertEqual(theme.gradient_end, BRAND_GRADIENT_END) + self.assertEqual(theme.text_color, "#ffffff") + + def test_falls_back_to_media_when_no_profile(self): + red_png = _make_solid_png(200, 10, 10) + theme = pick_theme(None, [red_png]) + self.assertNotEqual(theme.gradient_start, BRAND_GRADIENT_START) + + def test_media_takes_priority_over_profile(self): + blue_png = _make_solid_png(10, 10, 200) + red_png = _make_solid_png(200, 10, 10) + theme_with_media = pick_theme(blue_png, [red_png]) + theme_profile_only = pick_theme(blue_png, []) + self.assertNotEqual(theme_with_media.gradient_start, theme_profile_only.gradient_start) + + def test_returns_theme_colors_dataclass(self): + theme = pick_theme(None, []) + self.assertIsInstance(theme, ThemeColors) + + def test_all_grayscale_image_falls_back_gracefully(self): + gray_png = _make_solid_png(128, 128, 128) + theme = pick_theme(gray_png, []) + self.assertIsNotNone(theme.gradient_start) + self.assertIsNotNone(theme.gradient_end) + self.assertIn(theme.text_color, ["#000000", "#ffffff"]) + + +class ContrastTextTest(unittest.TestCase): + + def test_dark_background_returns_white_text(self): + self.assertEqual(_contrast_text((0, 0, 0)), "#ffffff") + self.assertEqual(_contrast_text((20, 20, 20)), "#ffffff") + self.assertEqual(_contrast_text((10, 10, 200)), "#ffffff") + + def test_light_background_returns_black_text(self): + self.assertEqual(_contrast_text((255, 255, 255)), "#000000") + self.assertEqual(_contrast_text((230, 230, 230)), "#000000") + self.assertEqual(_contrast_text((255, 240, 200)), "#000000") + + +class GradientDerivationTest(unittest.TestCase): + + def test_returns_different_color_from_input(self): + rgb = (100, 50, 200) + result = _derive_gradient_end(rgb) + self.assertNotEqual(result, rgb) + + def test_result_is_valid_rgb(self): + for rgb in [(0, 0, 0), (255, 255, 255), (100, 150, 200)]: + r, g, b = _derive_gradient_end(rgb) + self.assertGreaterEqual(r, 0) + self.assertLessEqual(r, 255) + self.assertGreaterEqual(g, 0) + self.assertLessEqual(g, 255) + self.assertGreaterEqual(b, 0) + self.assertLessEqual(b, 255) + + +class DominantColorTest(unittest.TestCase): + + def test_returns_none_for_empty_bytes(self): + self.assertIsNone(_dominant_from_bytes(None)) + self.assertIsNone(_dominant_from_bytes(b"")) + + def test_returns_tuple_for_valid_image(self): + png = _make_solid_png(200, 50, 50) + result = _dominant_from_bytes(png) + self.assertIsNotNone(result) + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 3) + + def test_returns_none_for_invalid_bytes(self): + self.assertIsNone(_dominant_from_bytes(b"not an image")) diff --git a/test/features/web_browsing/test_twitter_status_fetcher.py b/test/features/web_browsing/test_twitter_status_fetcher.py index 2cc3c3cd..703aff03 100644 --- a/test/features/web_browsing/test_twitter_status_fetcher.py +++ b/test/features/web_browsing/test_twitter_status_fetcher.py @@ -11,7 +11,7 @@ from db.schema.tools_cache import ToolsCache from di.di import DI from features.external_tools.tool_choice_resolver import ConfiguredTool -from features.web_browsing.twitter_status_fetcher import TwitterStatusFetcher +from features.web_browsing.twitter_status_fetcher import TweetData, TweetMediaItem, TwitterStatusFetcher from util.config import config @@ -283,3 +283,117 @@ def test_format_tweet_content_handles_missing_data(self, m: Mocker, _): self.assertIn("@testuser ()", result) self.assertIn("@testuser's bio: \"\"", result) self.assertIn("", result) + + @requests_mock.Mocker() + @patch("features.web_browsing.twitter_status_fetcher.sleep", return_value = None) + def test_as_structured_returns_typed_data(self, m: Mocker, _): + self.mock_di.tools_cache_crud.get.return_value = None + m.get( + self.api_url, + json = { + "data": { + "text": "Structured tweet text", + "lang": "en", + "created_at": "2026-05-04T14:13:00.000Z", + "author_id": "123", + }, + "includes": { + "users": [ + { + "id": "123", + "username": "structuser", + "name": "Structured User", + "description": "A bio", + "profile_image_url": "https://pbs.twimg.com/profile_images/1/photo_normal.jpg", + }, + ], + "media": [ + { + "type": "photo", + "url": "https://pbs.twimg.com/media/photo.jpg", + "preview_image_url": None, + }, + { + "type": "animated_gif", + "url": None, + "preview_image_url": "https://pbs.twimg.com/media/gif_preview.jpg", + }, + { + "type": "video", + "url": None, + "preview_image_url": "https://pbs.twimg.com/media/video_preview.jpg", + }, + ], + }, + }, + ) + fetcher = TwitterStatusFetcher( + tweet_id = "123456789", + x_api_tool = self.mock_x_api_tool, + vision_tool = self.mock_vision_tool, + di = self.mock_di, + ) + result = fetcher.as_structured() + + self.assertIsInstance(result, TweetData) + self.assertEqual(result.user.handle, "structuser") + self.assertEqual(result.user.name, "Structured User") + self.assertEqual(result.user.bio, "A bio") + self.assertIn("_normal", result.user.profile_image_url) + self.assertEqual(result.text, "Structured tweet text") + self.assertEqual(result.language, "en") + self.assertEqual(result.created_at, "2026-05-04T14:13:00.000Z") + self.assertEqual(len(result.media), 3) + self.assertIsInstance(result.media[0], TweetMediaItem) + self.assertEqual(result.media[0].media_type, "photo") + self.assertEqual(result.media[0].url, "https://pbs.twimg.com/media/photo.jpg") + self.assertEqual(result.media[1].media_type, "animated_gif") + self.assertEqual(result.media[1].preview_url, "https://pbs.twimg.com/media/gif_preview.jpg") + self.assertEqual(result.media[2].media_type, "video") + self.assertEqual(result.media[2].preview_url, "https://pbs.twimg.com/media/video_preview.jpg") + + @requests_mock.Mocker() + @patch("features.web_browsing.twitter_status_fetcher.sleep", return_value = None) + def test_as_structured_uses_structured_cache_prefix(self, m: Mocker, _): + self.mock_di.tools_cache_crud.get.return_value = None + m.get( + self.api_url, + json = {"data": {"text": "Test", "lang": "en"}, "includes": {"users": [{"username": "u"}]}}, + ) + fetcher = TwitterStatusFetcher( + tweet_id = "123456789", + x_api_tool = self.mock_x_api_tool, + vision_tool = self.mock_vision_tool, + di = self.mock_di, + ) + fetcher.as_structured() + + create_key_calls = self.mock_di.tools_cache_crud.create_key.call_args_list + prefixes_used = [call.args[0] for call in create_key_calls] + self.assertIn("twitter-status-fetcher-json", prefixes_used) + self.assertNotIn("twitter-status-fetcher", prefixes_used) + + @requests_mock.Mocker() + @patch("features.web_browsing.twitter_status_fetcher.sleep", return_value = None) + def test_as_structured_does_not_invoke_cv(self, m: Mocker, _): + self.mock_di.tools_cache_crud.get.return_value = None + m.get( + self.api_url, + json = { + "data": {"text": "Tweet", "lang": "en"}, + "includes": { + "users": [{"username": "u", "name": "U"}], + "media": [{"type": "photo", "url": "https://pbs.twimg.com/media/photo.jpg"}], + }, + }, + ) + fetcher = TwitterStatusFetcher( + tweet_id = "123456789", + x_api_tool = self.mock_x_api_tool, + vision_tool = self.mock_vision_tool, + di = self.mock_di, + ) + fetcher.as_structured() + + # noinspection PyUnresolvedReferences + self.mock_di.computer_vision_analyzer.assert_not_called() diff --git a/test/fixtures/logos.yaml b/test/fixtures/logos.yaml new file mode 100644 index 00000000..02409ff4 --- /dev/null +++ b/test/fixtures/logos.yaml @@ -0,0 +1,6 @@ +logos: + agent_logo_color: "https://example.com/logo-color.svg" + agent_logo_light: "https://example.com/logo-light.svg" + agent_logo_dark: "https://example.com/logo-dark.svg" + x_logo_light: "https://example.com/x-light.svg" + x_logo_dark: "https://example.com/x-dark.svg" diff --git a/test/util/test_config.py b/test/util/test_config.py index 25c2ad03..397f557e 100644 --- a/test/util/test_config.py +++ b/test/util/test_config.py @@ -4,6 +4,7 @@ from util.config import Config PRODUCTS_FIXTURE_PATH = "test/fixtures/products.yaml" +LOGOS_FIXTURE_PATH = "test/fixtures/logos.yaml" class ConfigTest(unittest.TestCase): @@ -67,6 +68,8 @@ def test_default_config(self): self.assertEqual(config.version, "dev") self.assertEqual(config.usage_maintenance_fee_credits, 0.0) self.assertEqual(config.products_config_path, "config/products.yaml") + self.assertEqual(config.logos_config_path, "config/logos.yaml") + self.assertEqual(config.font_path, "src/assets/fonts/Heebo-Variable.ttf") self.assertEqual(config.db_url.get_secret_value(), "postgresql://root:root@localhost:5432/agent") self.assertTrue(config.api_key.get_secret_value()) # Check if API key is generated @@ -134,6 +137,7 @@ def test_custom_config(self): os.environ["URL_SHORTENER_BASE_URL"] = "https://custom.to.appifyhub.com" os.environ["VERSION"] = "custom" os.environ["USAGE_MAINTENANCE_FEE_CREDITS"] = "0.5" + os.environ["FONT_PATH"] = "/custom/path/font.ttf" os.environ["POSTGRES_USER"] = "admin" os.environ["POSTGRES_PASS"] = "admin123" @@ -206,6 +210,7 @@ def test_custom_config(self): self.assertEqual(config.url_shortener_base_url, "https://custom.to.appifyhub.com") self.assertEqual(config.version, "custom") self.assertEqual(config.usage_maintenance_fee_credits, 0.5) + self.assertEqual(config.font_path, "/custom/path/font.ttf") self.assertEqual(config.db_url.get_secret_value(), "postgresql://admin:admin123@db.example.com:5432/test_db") self.assertEqual(config.api_key.get_secret_value(), "1111-2222-3333-4444") @@ -250,3 +255,22 @@ def test_products_loaded_once_at_init(self): second = config.products self.assertIs(first, second) + + def test_logos_loaded_from_yaml(self): + config = Config(def_logos_config_path = LOGOS_FIXTURE_PATH) + logos = config.logos + + self.assertEqual(len(logos), 5) + self.assertEqual(logos["agent_logo_color"], "https://example.com/logo-color.svg") + self.assertEqual(logos["agent_logo_light"], "https://example.com/logo-light.svg") + self.assertEqual(logos["agent_logo_dark"], "https://example.com/logo-dark.svg") + self.assertEqual(logos["x_logo_light"], "https://example.com/x-light.svg") + self.assertEqual(logos["x_logo_dark"], "https://example.com/x-dark.svg") + + def test_logos_loaded_once_at_init(self): + config = Config(def_logos_config_path = LOGOS_FIXTURE_PATH) + + first = config.logos + second = config.logos + + self.assertIs(first, second)