From 90e1719423d8382a09a034dd53acb3affdae2d6e Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Wed, 6 May 2026 23:23:44 +0200 Subject: [PATCH 1/4] Add the social media post rendering feature --- Pipfile | 1 + Pipfile.lock | 863 ++++++++++-------- config/logos.yaml | 8 + src/assets/fonts/Heebo-Variable.ttf | Bin 0 -> 117508 bytes src/assets/fonts/OFL.txt | 93 ++ src/di/di.py | 12 + src/features/chat/chat_image_edit_service.py | 1 - .../chat/llm_tools/llm_tool_library.py | 26 + src/features/images/smart_image_generator.py | 1 - src/features/social_cards/brand.py | 2 + src/features/social_cards/card_layout.py | 30 + src/features/social_cards/card_renderer.py | 31 + src/features/social_cards/card_template.py | 384 ++++++++ .../social_cards/social_card_orchestrator.py | 65 ++ src/features/social_cards/theme.py | 115 +++ src/features/web_browsing/photo_downloader.py | 40 + .../web_browsing/twitter_status_fetcher.py | 132 ++- src/util/config.py | 18 + .../chat/test_chat_image_edit_service.py | 1 - .../images/test_smart_image_generator.py | 1 - .../test_social_card_orchestrator.py | 233 +++++ test/features/social_cards/test_theme.py | 94 ++ .../test_twitter_status_fetcher.py | 116 ++- test/fixtures/logos.yaml | 6 + test/util/test_config.py | 24 + 25 files changed, 1892 insertions(+), 405 deletions(-) create mode 100644 config/logos.yaml create mode 100644 src/assets/fonts/Heebo-Variable.ttf create mode 100644 src/assets/fonts/OFL.txt create mode 100644 src/features/social_cards/brand.py create mode 100644 src/features/social_cards/card_layout.py create mode 100644 src/features/social_cards/card_renderer.py create mode 100644 src/features/social_cards/card_template.py create mode 100644 src/features/social_cards/social_card_orchestrator.py create mode 100644 src/features/social_cards/theme.py create mode 100644 src/features/web_browsing/photo_downloader.py create mode 100644 test/features/social_cards/test_social_card_orchestrator.py create mode 100644 test/features/social_cards/test_theme.py create mode 100644 test/fixtures/logos.yaml 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/src/assets/fonts/Heebo-Variable.ttf b/src/assets/fonts/Heebo-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..acc085c6111ef0531d26d5395527ed4cf858c815 GIT binary patch literal 117508 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 _Ow2( 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 "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..53d1b875 --- /dev/null +++ b/src/features/social_cards/brand.py @@ -0,0 +1,2 @@ +BRAND_GRADIENT_START = "#040b19" +BRAND_GRADIENT_END = "#251A3D" 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..4e497bb7 --- /dev/null +++ b/src/features/social_cards/theme.py @@ -0,0 +1,115 @@ +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", + ) + gradient_end = _derive_gradient_end(primary) + text_color = _contrast_text(primary) + return ThemeColors( + gradient_start = _rgb_to_hex(primary), + gradient_end = _rgb_to_hex(gradient_end), + 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) From 5fb8fcae74fea53be3a9cdc851ceb328e1b3ac99 Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Wed, 6 May 2026 23:33:50 +0200 Subject: [PATCH 2/4] Fix the social card gradient direction (light goes to the top left) --- src/features/social_cards/brand.py | 4 ++-- src/features/social_cards/theme.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/features/social_cards/brand.py b/src/features/social_cards/brand.py index 53d1b875..31cea191 100644 --- a/src/features/social_cards/brand.py +++ b/src/features/social_cards/brand.py @@ -1,2 +1,2 @@ -BRAND_GRADIENT_START = "#040b19" -BRAND_GRADIENT_END = "#251A3D" +BRAND_GRADIENT_START = "#251A3D" +BRAND_GRADIENT_END = "#040b19" diff --git a/src/features/social_cards/theme.py b/src/features/social_cards/theme.py index 4e497bb7..82ac8800 100644 --- a/src/features/social_cards/theme.py +++ b/src/features/social_cards/theme.py @@ -28,11 +28,12 @@ def pick_theme( gradient_end = BRAND_GRADIENT_END, text_color = "#ffffff", ) - gradient_end = _derive_gradient_end(primary) + 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(primary), - gradient_end = _rgb_to_hex(gradient_end), + gradient_start = _rgb_to_hex(light), + gradient_end = _rgb_to_hex(dark), text_color = text_color, ) From 12f69a34553454f64ec6080d6aae0cd1554b0944 Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Wed, 6 May 2026 23:34:06 +0200 Subject: [PATCH 3/4] Add open specs for the social card rendering --- .../.openspec.yaml | 2 + .../design.md | 133 ++++++++++++++ .../proposal.md | 32 ++++ .../specs/social-post-card-rendering/spec.md | 163 ++++++++++++++++++ .../tasks.md | 60 +++++++ 5 files changed, 390 insertions(+) create mode 100644 openspec/changes/archive/2026-05-06-add-social-post-card-tool/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-06-add-social-post-card-tool/design.md create mode 100644 openspec/changes/archive/2026-05-06-add-social-post-card-tool/proposal.md create mode 100644 openspec/changes/archive/2026-05-06-add-social-post-card-tool/specs/social-post-card-rendering/spec.md create mode 100644 openspec/changes/archive/2026-05-06-add-social-post-card-tool/tasks.md 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 From e440fae485bf5c1375645b5172641ca6a1f5d2ff Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Wed, 6 May 2026 23:35:32 +0200 Subject: [PATCH 4/4] =?UTF-8?q?RELEASE=205.12.0=20=E2=80=93=20Agent=20can?= =?UTF-8?q?=20now=20render=20social=20cards=20/=20screenshots=20for=20you,?= =?UTF-8?q?=20starting=20with=20X/Twitter,=20and=20expanding=20to=20others?= =?UTF-8?q?=20soon!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/open-api-docs.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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"}